From 1dd0756c608b9c488da94d35daf911ed2504dfbd Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Thu, 4 Jun 2026 20:28:32 +0100 Subject: [PATCH] Add support for Guzzle 8 --- composer.json | 6 +- src/Credentials/EcsCredentialProvider.php | 9 +- src/Handler/Guzzle/GuzzleHandler.php | 25 ++-- src/Handler/HttpHandlerError.php | 56 +++++++++ src/Retry/V3/RetryMiddleware.php | 54 +-------- src/RetryMiddleware.php | 39 +------ src/RetryMiddlewareV2.php | 36 +----- tests/CreatesGuzzleExceptionsTrait.php | 26 +++++ .../Credentials/EcsCredentialProviderTest.php | 70 ++++++++---- .../InstanceProfileProviderTest.php | 33 +++--- tests/Handler/Guzzle/HandlerTest.php | 107 +++++++++++++++++- tests/Handler/HttpHandlerErrorTest.php | 99 ++++++++++++++++ tests/Retry/V3/RetryMiddlewareTest.php | 66 ----------- tests/RetryMiddlewareTest.php | 66 ----------- tests/RetryMiddlewareV2Test.php | 68 ----------- tests/S3/S3ClientTest.php | 27 ++--- tests/WaiterTest.php | 4 +- 17 files changed, 386 insertions(+), 405 deletions(-) create mode 100644 src/Handler/HttpHandlerError.php create mode 100644 tests/CreatesGuzzleExceptionsTrait.php create mode 100644 tests/Handler/HttpHandlerErrorTest.php diff --git a/composer.json b/composer.json index d9698ea05d..7f69929a45 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,9 @@ }, "require": { "php": ">=8.1", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/psr7": "^2.4.5", - "guzzlehttp/promises": "^2.0", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", "mtdowling/jmespath.php": "^2.8.0", "ext-pcre": "*", "ext-json": "*", diff --git a/src/Credentials/EcsCredentialProvider.php b/src/Credentials/EcsCredentialProvider.php index e95b2b005b..be593eae05 100644 --- a/src/Credentials/EcsCredentialProvider.php +++ b/src/Credentials/EcsCredentialProvider.php @@ -3,7 +3,7 @@ use Aws\Arn\Arn; use Aws\Exception\CredentialsException; -use GuzzleHttp\Exception\ConnectException; +use Aws\Handler\HttpHandlerError; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Promise; @@ -105,13 +105,14 @@ public function __invoke() CredentialSources::ECS ); })->otherwise(function ($reason) { - $reason = is_array($reason) ? $reason['exception'] : $reason; + $connectionError = is_array($reason) && !empty($reason['connection_error']); + $exception = is_array($reason) ? ($reason['exception'] ?? null) : $reason; + $isRetryable = $connectionError || ($exception instanceof \Throwable && HttpHandlerError::isConnectionError($exception)); - $isRetryable = $reason instanceof ConnectException; if ($isRetryable && ($this->attempts < $this->retries)) { sleep((int)pow(1.2, $this->attempts)); } else { - $msg = $reason->getMessage(); + $msg = $exception instanceof \Throwable ? $exception->getMessage() : \Aws\describe_type($reason); throw new CredentialsException( sprintf('Error retrieving credentials from container metadata after attempt %d/%d (%s)', $this->attempts, $this->retries, $msg) ); diff --git a/src/Handler/Guzzle/GuzzleHandler.php b/src/Handler/Guzzle/GuzzleHandler.php index 41532be324..ab5fcea08c 100644 --- a/src/Handler/Guzzle/GuzzleHandler.php +++ b/src/Handler/Guzzle/GuzzleHandler.php @@ -1,9 +1,7 @@ client->sendAsync($request, $this->parseOptions($options)) ->otherwise( - static function ($e) { - $error = [ + static function (\Throwable $e) { + return new Promise\RejectedPromise([ 'exception' => $e, - 'connection_error' => $e instanceof ConnectException, - 'response' => null, - ]; - - if ( - ($e instanceof RequestException) - && $e->getResponse() - ) { - $error['response'] = $e->getResponse(); - } - - return new Promise\RejectedPromise($error); + 'connection_error' => HttpHandlerError::isConnectionError($e), + 'response' => HttpHandlerError::getResponse($e), + ]); } ); } diff --git a/src/Handler/HttpHandlerError.php b/src/Handler/HttpHandlerError.php new file mode 100644 index 0000000000..a889a72000 --- /dev/null +++ b/src/Handler/HttpHandlerError.php @@ -0,0 +1,56 @@ +getHandlerContext(); + + return !empty($context['errno']) && $context['errno'] === self::CURLE_RECV_ERROR; + } + + return false; + } + + public static function getResponse(\Throwable $exception): ?ResponseInterface + { + // Guzzle 8: response-aware failures expose the response through ResponseException. + if ($exception instanceof ResponseException) { + return $exception->getResponse(); + } + + // Guzzle 7: RequestException directly carried an optional response. + if ($exception instanceof RequestException && is_callable([$exception, 'getResponse']) + ) { + return $exception->getResponse(); + } + + return null; + } +} diff --git a/src/Retry/V3/RetryMiddleware.php b/src/Retry/V3/RetryMiddleware.php index e009a9fb55..e3b9de208f 100644 --- a/src/Retry/V3/RetryMiddleware.php +++ b/src/Retry/V3/RetryMiddleware.php @@ -8,7 +8,6 @@ use Aws\Retry\ConfigurationInterface; use Aws\Retry\RateLimiter; use Aws\Retry\RetryHelperTrait; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Promise; use GuzzleHttp\Promise\PromiseInterface; use Psr\Http\Message\RequestInterface; @@ -72,7 +71,6 @@ class RetryMiddleware private array $options; private QuotaManager $quotaManager; private ?RateLimiter $rateLimiter = null; - private array $retryCurlErrors; private ?string $service; public static function wrap(ConfigurationInterface $config, array $options): \Closure @@ -84,23 +82,17 @@ public static function wrap(ConfigurationInterface $config, array $options): \Cl /** * Returns a closure that decides retryability for a given result based - * on the standard error codes, status codes, and curl errors. Quota and - * max-attempts decisions are handled by the middleware itself, not by - * this closure. + * on the standard error codes and status codes. Quota and max-attempts + * decisions are handled by the middleware itself, not by this closure. */ public static function createDefaultDecider(array $options = []): \Closure { - $retryCurlErrors = []; - if (extension_loaded('curl')) { - $retryCurlErrors[CURLE_RECV_ERROR] = true; - } - return function ( int $attempts, CommandInterface $command, mixed $result - ) use ($options, $retryCurlErrors): bool { - return self::isRetryable($result, $retryCurlErrors, $options); + ) use ($options): bool { + return self::isRetryable($result, $options); }; } @@ -128,16 +120,6 @@ public function __construct( ? ($options['delayer'])(...) : null; - $this->retryCurlErrors = []; - if (extension_loaded('curl')) { - $this->retryCurlErrors[CURLE_RECV_ERROR] = true; - } - if (!empty($options['curl_errors']) && is_array($options['curl_errors'])) { - foreach ($options['curl_errors'] as $code) { - $this->retryCurlErrors[$code] = true; - } - } - if ($this->mode === 'adaptive') { $this->rateLimiter = $options['rate_limiter'] ?? new RateLimiter(); } @@ -198,7 +180,6 @@ public function __invoke(CommandInterface $cmd, RequestInterface $req): PromiseI $isRetryable = self::isRetryable( $value, - $this->retryCurlErrors, $this->options ); @@ -342,7 +323,6 @@ private function computeRetryDelay(int $attemptIndex, bool $isThrottling, mixed private static function isRetryable( mixed $result, - array $retryCurlErrors, array $options = [] ): bool { @@ -371,14 +351,6 @@ private static function isRetryable( } } - if (!empty($options['curl_errors']) - && is_array($options['curl_errors']) - ) { - foreach ($options['curl_errors'] as $code) { - $retryCurlErrors[$code] = true; - } - } - $isError = $result instanceof \Throwable; if (!$isError) { @@ -406,24 +378,6 @@ private static function isRetryable( return true; } - if (count($retryCurlErrors) - && ($previous = $result->getPrevious()) - && $previous instanceof RequestException - ) { - if (method_exists($previous, 'getHandlerContext')) { - $context = $previous->getHandlerContext(); - return !empty($context['errno']) - && isset($retryCurlErrors[$context['errno']]); - } - - $message = $previous->getMessage(); - foreach (array_keys($retryCurlErrors) as $curlError) { - if (str_starts_with($message, 'cURL error ' . $curlError . ':')) { - return true; - } - } - } - if (!empty($errorShape = $result->getAwsErrorShape())) { $definition = $errorShape->toArray(); if (!empty($definition['retryable'])) { diff --git a/src/RetryMiddleware.php b/src/RetryMiddleware.php index 9c9f1e20a9..702402061f 100644 --- a/src/RetryMiddleware.php +++ b/src/RetryMiddleware.php @@ -3,7 +3,6 @@ use Aws\Exception\AwsException; use Aws\Retry\RetryHelperTrait; -use GuzzleHttp\Exception\RequestException; use Psr\Http\Message\RequestInterface; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Promise; @@ -67,9 +66,6 @@ public function __construct( * Optional. * - statusCodes: (int[]) An indexed array of HTTP status codes to retry. * Optional. - * - curlErrors: (int[]) An indexed array of Curl error codes to retry. Note - * these should be valid Curl constants. Optional. - * * @param int $maxRetries * @param array $extraConfig * @return callable @@ -78,18 +74,13 @@ public static function createDefaultDecider( $maxRetries = 3, $extraConfig = [] ) { - $retryCurlErrors = []; - if (extension_loaded('curl')) { - $retryCurlErrors[CURLE_RECV_ERROR] = true; - } - return function ( $retries, CommandInterface $command, RequestInterface $request, ?ResultInterface $result = null, $error = null - ) use ($maxRetries, $retryCurlErrors, $extraConfig) { + ) use ($maxRetries, $extraConfig) { // Allow command-level options to override this value $maxRetries = null !== $command['@retries'] ? $command['@retries'] @@ -98,7 +89,6 @@ public static function createDefaultDecider( $isRetryable = self::isRetryable( $result, $error, - $retryCurlErrors, $extraConfig ); @@ -119,7 +109,6 @@ public static function createDefaultDecider( private static function isRetryable( $result, $error, - $retryCurlErrors, $extraConfig = [] ) { $errorCodes = self::$retryCodes; @@ -140,14 +129,6 @@ private static function isRetryable( } } - if (!empty($extraConfig['curl_errors']) - && is_array($extraConfig['curl_errors']) - ) { - foreach($extraConfig['curl_errors'] as $code) { - $retryCurlErrors[$code] = true; - } - } - if (!$error) { if (!isset($result['@metadata']['statusCode'])) { return false; @@ -173,24 +154,6 @@ private static function isRetryable( return true; } - if (count($retryCurlErrors) - && ($previous = $error->getPrevious()) - && $previous instanceof RequestException - ) { - if (method_exists($previous, 'getHandlerContext')) { - $context = $previous->getHandlerContext(); - return !empty($context['errno']) - && isset($retryCurlErrors[$context['errno']]); - } - - $message = $previous->getMessage(); - foreach (array_keys($retryCurlErrors) as $curlError) { - if (strpos($message, 'cURL error ' . $curlError . ':') === 0) { - return true; - } - } - } - return false; } diff --git a/src/RetryMiddlewareV2.php b/src/RetryMiddlewareV2.php index 79d208e16f..c3febe2d9e 100644 --- a/src/RetryMiddlewareV2.php +++ b/src/RetryMiddlewareV2.php @@ -7,7 +7,6 @@ use Aws\Retry\RateLimiter; use Aws\Retry\RetryHelperTrait; use Exception; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Promise; use Psr\Http\Message\RequestInterface; @@ -80,16 +79,11 @@ public static function createDefaultDecider( $maxAttempts = 3, $options = [] ) { - $retryCurlErrors = []; - if (extension_loaded('curl')) { - $retryCurlErrors[CURLE_RECV_ERROR] = true; - } - return function( $attempts, CommandInterface $command, $result - ) use ($options, $quotaManager, $retryCurlErrors, $maxAttempts) { + ) use ($options, $quotaManager, $maxAttempts) { // Release retry tokens back to quota on a successful result $quotaManager->releaseToQuota($result); @@ -102,7 +96,6 @@ public static function createDefaultDecider( $isRetryable = self::isRetryable( $result, - $retryCurlErrors, $options ); @@ -261,7 +254,6 @@ public function exponentialDelayWithJitter($attempts) private static function isRetryable( $result, - $retryCurlErrors, $options = [] ) { $errorCodes = self::$standardThrottlingErrors + self::$standardTransientErrors; @@ -289,14 +281,6 @@ private static function isRetryable( } } - if (!empty($options['curl_errors']) - && is_array($options['curl_errors']) - ) { - foreach($options['curl_errors'] as $code) { - $retryCurlErrors[$code] = true; - } - } - if ($result instanceof Exception || $result instanceof \Throwable) { $isError = true; } else { @@ -328,24 +312,6 @@ private static function isRetryable( return true; } - if (count($retryCurlErrors) - && ($previous = $result->getPrevious()) - && $previous instanceof RequestException - ) { - if (method_exists($previous, 'getHandlerContext')) { - $context = $previous->getHandlerContext(); - return !empty($context['errno']) - && isset($retryCurlErrors[$context['errno']]); - } - - $message = $previous->getMessage(); - foreach (array_keys($retryCurlErrors) as $curlError) { - if (strpos($message, 'cURL error ' . $curlError . ':') === 0) { - return true; - } - } - } - // Check error shape for the retryable trait if (!empty($errorShape = $result->getAwsErrorShape())) { $definition = $errorShape->toArray(); diff --git a/tests/CreatesGuzzleExceptionsTrait.php b/tests/CreatesGuzzleExceptionsTrait.php new file mode 100644 index 0000000000..45a744982d --- /dev/null +++ b/tests/CreatesGuzzleExceptionsTrait.php @@ -0,0 +1,26 @@ + true, + 'exception' => new \Exception('cURL error 28: Connection timed out after 1000 milliseconds'), + ]); $connectException = new ConnectException( 'cURL error 28: Connection timed out after 1000 milliseconds', new Psr7\Request('GET', '/latest') ); - $rejectionConnection = Promise\Create::rejectionFor([ + $rejectionWrappedConnectException = Promise\Create::rejectionFor([ 'exception' => $connectException, ]); + $rejectionRawConnectException = Promise\Create::rejectionFor($connectException); $promiseCreds = Promise\Create::promiseFor( new Response(200, [], Psr7\Utils::streamFor( @@ -431,22 +438,42 @@ public static function successDataProvider(): array ], $credsObject ], - 'With retries for ConnectException (Guzzle 7)' => [ + 'With retries for connection_error metadata' => [ + [ + 'responses' => [ + $rejectionConnectionError, + $promiseCreds + ], + 'credentials' => $creds + ], + $credsObject + ], + 'With retries for wrapped ConnectException' => [ + [ + 'responses' => [ + $rejectionWrappedConnectException, + $promiseCreds + ], + 'credentials' => $creds + ], + $credsObject + ], + 'With retries for raw ConnectException' => [ [ 'responses' => [ - $rejectionConnection, + $rejectionRawConnectException, $promiseCreds ], 'credentials' => $creds ], $credsObject ], - 'With 4 retries for ConnectException (Guzzle 7)' => [ + 'With 4 retries for connection_error metadata' => [ [ 'responses' => [ - $rejectionConnection, - $rejectionConnection, - $rejectionConnection, + $rejectionConnectionError, + $rejectionConnectionError, + $rejectionConnectionError, $promiseCreds ], 'credentials' => $creds @@ -488,18 +515,15 @@ public static function failureDataProvider(): array $getRequest = new Psr7\Request('GET', '/latest'); $rejectionCreds = Promise\Create::rejectionFor([ - 'exception' => new RequestException( + 'exception' => self::createRequestException( '401 Unathorized', $getRequest, new Psr7\Response(401) ) ]); - $connectException = new ConnectException( - 'cURL error 28: Connection timed out after 1000 milliseconds', - new Psr7\Request('GET', '/latest') - ); $rejectionConnection = Promise\Create::rejectionFor([ - 'exception' => $connectException, + 'connection_error' => true, + 'exception' => new \Exception('cURL error 28: Connection timed out after 1000 milliseconds'), ]); return [ @@ -527,12 +551,9 @@ public function testReadsRetriesFromEnvironment() { putenv('AWS_METADATA_SERVICE_NUM_ATTEMPTS=1'); - $connectException = new ConnectException( - 'cURL error 28: Connection timed out after 1000 milliseconds', - new Psr7\Request('GET', '/latest') - ); $rejectionConnection = Promise\Create::rejectionFor([ - 'exception' => $connectException, + 'connection_error' => true, + 'exception' => new \Exception('cURL error 28: Connection timed out after 1000 milliseconds'), ]); $provider = new EcsCredentialProvider([ @@ -567,12 +588,9 @@ public function testAttemptsAreResetWhenProviderIsWhenSameProviderIsReused() $credsObject = new Credentials($creds[0], $creds[1], $creds[2], $expiry); - $connectException = new ConnectException( - 'cURL error 28: Connection timed out after 1000 milliseconds', - new Psr7\Request('GET', '/latest') - ); $rejectionConnection = Promise\Create::rejectionFor([ - 'exception' => $connectException, + 'connection_error' => true, + 'exception' => new \Exception('cURL error 28: Connection timed out after 1000 milliseconds'), ]); $promiseCreds = Promise\Create::promiseFor( new Response(200, [], Psr7\Utils::streamFor( @@ -648,6 +666,10 @@ private function getTestClient( $creds ) { if (!empty($responses)) { + if (!isset($responses[$getRequests])) { + throw new \Exception('No responses'); + } + return $responses[$getRequests++]; } return Promise\Create::promiseFor( diff --git a/tests/Credentials/InstanceProfileProviderTest.php b/tests/Credentials/InstanceProfileProviderTest.php index 3629077b25..1475cf51b2 100644 --- a/tests/Credentials/InstanceProfileProviderTest.php +++ b/tests/Credentials/InstanceProfileProviderTest.php @@ -10,6 +10,7 @@ use Aws\Result; use Aws\S3\S3Client; use Aws\Sdk; +use Aws\Test\CreatesGuzzleExceptionsTrait; use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Promise; use GuzzleHttp\Psr7; @@ -24,6 +25,8 @@ #[CoversClass(InstanceProfileProvider::class)] class InstanceProfileProviderTest extends TestCase { + use CreatesGuzzleExceptionsTrait; + private $originalEnv = []; private $tempFiles = []; private $capturedUri = null; @@ -165,7 +168,7 @@ private static function getSecureTestClient( $request ); } else { - $exception = new RequestException( + $exception = self::createRequestException( '401 Unauthorized - Valid unexpired token required', $request, new Response(401) @@ -257,7 +260,7 @@ private static function getInsecureTestClient( ) ); } else { - $exception = new RequestException( + $exception = self::createRequestException( '404 Not Found', // Needed for different interfaces in Guzzle V5 & V6 new $requestClass( @@ -377,12 +380,12 @@ public static function successDataProvider(): \Generator $putRequest = new $requestClass('PUT', '/latest/meta-data/foo'); $throttledResponse = new $responseClass(503); - $getThrottleException = new RequestException( + $getThrottleException = self::createRequestException( '503 ThrottlingException', $getRequest, $throttledResponse ); - $putThrottleException = new RequestException( + $putThrottleException = self::createRequestException( '503 ThrottlingException', $putRequest, $throttledResponse @@ -604,35 +607,35 @@ public static function failureDataProvider(): \Generator new Response(200, [], Psr7\Utils::streamFor('{')) ); $rejectionThrottleToken = Promise\Create::rejectionFor([ - 'exception' => new RequestException( + 'exception' => self::createRequestException( '503 ThrottlingException', $putRequest, new $responseClass(503) ) ]); $rejectionProfile = Promise\Create::rejectionFor([ - 'exception' => new RequestException( + 'exception' => self::createRequestException( '401 Unathorized', $getRequest, new $responseClass(401) ) ]); $rejectionThrottleProfile = Promise\Create::rejectionFor([ - 'exception' => new RequestException( + 'exception' => self::createRequestException( '503 ThrottlingException', $getRequest, new $responseClass(503) ) ]); $rejectionCreds = Promise\Create::rejectionFor([ - 'exception' => new RequestException( + 'exception' => self::createRequestException( '401 Unathorized', $getRequest, new $responseClass(401) ) ]); $rejectionThrottleCreds = Promise\Create::rejectionFor([ - 'exception' => new RequestException( + 'exception' => self::createRequestException( '503 ThrottlingException', $getRequest, new $responseClass(503) @@ -855,7 +858,7 @@ public function testSwitchesBackToSecureModeOn401() ) { if ($reqNumber === 1) { return Promise\Create::rejectionFor([ - 'exception' => new RequestException('404 Not Found', + 'exception' => self::createRequestException('404 Not Found', $putRequest, new $responseClass(404) ) @@ -868,7 +871,7 @@ public function testSwitchesBackToSecureModeOn401() } if ($request->getMethod() === 'GET') { return Promise\Create::rejectionFor([ - 'exception' => new RequestException( + 'exception' => self::createRequestException( '401 Unauthorized - Valid unexpired token required', $getRequest, new $responseClass(401) @@ -1150,14 +1153,14 @@ public static function imdsUnavailableProvider() $putRequest = new $requestClass('PUT', '/latest/meta-data/foo'); $profileRejection500 = Promise\Create::rejectionFor([ - 'exception' => new RequestException( + 'exception' => self::createRequestException( '500 internal server error', $putRequest, new $responseClass(500) ) ]); $credsRejection500 = Promise\Create::rejectionFor([ - 'exception' => new RequestException( + 'exception' => self::createRequestException( '500 internal server error', $getRequest, new $responseClass(500) @@ -1338,14 +1341,14 @@ private function fetchMockedCredentialsAndAlwaysExpectAToken($config=[]) { $mockHandler = function (RequestInterface $request) use (&$firstTokenTry, $mockToken, $TOKEN_HEADER_KEY) { $fnRejectionTokenNotProvided = function () use ($mockToken, $TOKEN_HEADER_KEY, $request) { return Promise\Create::rejectionFor( - ['exception' => new RequestException("Token with value $mockToken is expected as header $TOKEN_HEADER_KEY", $request, new Response(400))] + ['exception' => self::createRequestException("Token with value $mockToken is expected as header $TOKEN_HEADER_KEY", $request, new Response(400))] ); }; if ($request->getMethod() === 'PUT' && $request->getUri()->getPath() === '/latest/api/token') { if ($firstTokenTry) { $firstTokenTry = false; - return Promise\Create::rejectionFor(['exception' => new RequestException("Unexpected error!", $request, new Response(401))]); + return Promise\Create::rejectionFor(['exception' => self::createRequestException("Unexpected error!", $request, new Response(401))]); } else { return Promise\Create::promiseFor(new Response(200, [], Psr7\Utils::streamFor($mockToken))); } diff --git a/tests/Handler/Guzzle/HandlerTest.php b/tests/Handler/Guzzle/HandlerTest.php index e8477af940..1fa921814b 100644 --- a/tests/Handler/Guzzle/HandlerTest.php +++ b/tests/Handler/Guzzle/HandlerTest.php @@ -2,8 +2,12 @@ namespace Aws\Test\Handler\Guzzle; use Aws\Handler\Guzzle\GuzzleHandler; +use Aws\Test\CreatesGuzzleExceptionsTrait; use GuzzleHttp\Client; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\NetworkException; use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\ResponseTransferException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Promise\RejectionException; use GuzzleHttp\Psr7; @@ -16,6 +20,8 @@ #[CoversClass(GuzzleHandler::class)] class HandlerTest extends TestCase { + use CreatesGuzzleExceptionsTrait; + public function testHandlerWorksWithSuccessfulRequest() { $mock = new MockHandler([new Response(200, [], Psr7\Utils::streamFor('foo'))]); @@ -37,10 +43,10 @@ public function testHandlerWorksWithFailedRequest() { $wasRejected = false; $request = new Request('PUT', 'http://example.com'); - $mock = new MockHandler([new RequestException( + $mock = new MockHandler([self::createRequestException( 'message', $request, - new Response('500') + new Response(500) )]); $client = new Client(['handler' => $mock]); $handler = new GuzzleHandler($client); @@ -64,6 +70,103 @@ public function testHandlerWorksWithFailedRequest() $this->assertTrue($wasRejected, 'Reject callback was not triggered.'); } + public function testHandlerMarksConnectExceptionAsConnectionError() + { + $request = new Request('PUT', 'http://example.com'); + $mock = new MockHandler([ + new ConnectException('message', $request), + ]); + $client = new Client(['handler' => $mock]); + $handler = new GuzzleHandler($client); + + $promise = $handler($request); + + try { + $promise->wait(); + $this->fail('An exception should have been thrown.'); + } catch (RejectionException $e) { + $error = $e->getReason(); + $this->assertTrue($error['connection_error']); + $this->assertNull($error['response']); + } + } + + public function testHandlerMarksCurlRecvErrorAsConnectionError() + { + $request = new Request('PUT', 'http://example.com'); + $exception = new class ('message', $request) extends RequestException { + public function getHandlerContext(): array + { + return ['errno' => 56]; + } + }; + $mock = new MockHandler([$exception]); + $client = new Client(['handler' => $mock]); + $handler = new GuzzleHandler($client); + + $promise = $handler($request); + + try { + $promise->wait(); + $this->fail('An exception should have been thrown.'); + } catch (RejectionException $e) { + $error = $e->getReason(); + $this->assertTrue($error['connection_error']); + $this->assertNull($error['response']); + } + } + + public function testHandlerMarksNetworkExceptionAsConnectionError() + { + if (!class_exists(NetworkException::class)) { + $this->markTestSkipped('NetworkException is only available in Guzzle 8.'); + } + + $request = new Request('PUT', 'http://example.com'); + $mock = new MockHandler([ + new NetworkException('message', $request), + ]); + $client = new Client(['handler' => $mock]); + $handler = new GuzzleHandler($client); + + $promise = $handler($request); + + try { + $promise->wait(); + $this->fail('An exception should have been thrown.'); + } catch (RejectionException $e) { + $error = $e->getReason(); + $this->assertTrue($error['connection_error']); + $this->assertNull($error['response']); + } + } + + public function testHandlerMarksResponseTransferExceptionAsConnectionError() + { + if (!class_exists(ResponseTransferException::class)) { + $this->markTestSkipped('ResponseTransferException is only available in Guzzle 8.'); + } + + $request = new Request('PUT', 'http://example.com'); + $response = new Response(200); + $mock = new MockHandler([ + new ResponseTransferException('message', $request, $response), + ]); + $client = new Client(['handler' => $mock]); + $handler = new GuzzleHandler($client); + + $promise = $handler($request); + + try { + $promise->wait(); + $this->fail('An exception should have been thrown.'); + } catch (RejectionException $e) { + $error = $e->getReason(); + $this->assertTrue($error['connection_error']); + $this->assertSame($response, $error['response']); + } + } + public function testHandlerWillInvokeOnTransferStatsCallback() { $mock = new MockHandler([new Response(200, [], Psr7\Utils::streamFor('foo'))]); diff --git a/tests/Handler/HttpHandlerErrorTest.php b/tests/Handler/HttpHandlerErrorTest.php new file mode 100644 index 0000000000..0655c914d9 --- /dev/null +++ b/tests/Handler/HttpHandlerErrorTest.php @@ -0,0 +1,99 @@ +assertTrue(HttpHandlerError::isConnectionError($exception)); + } + + public function testDetectsNetworkException() + { + if (!class_exists(NetworkException::class)) { + $this->markTestSkipped('NetworkException is only available in Guzzle 8.'); + } + + $exception = new NetworkException( + 'test', + new Request('GET', 'http://example.com') + ); + + $this->assertTrue(HttpHandlerError::isConnectionError($exception)); + } + + public function testDetectsGuzzle7CurlRecvErrorFromRequestException() + { + $exception = new class ('test', new Request('GET', 'http://example.com')) extends RequestException { + public function getHandlerContext(): array + { + return ['errno' => 56]; + } + }; + + $this->assertTrue(HttpHandlerError::isConnectionError($exception)); + } + + public function testIgnoresGuzzle7NonRecvCurlErrorFromRequestException() + { + $exception = new class ('test', new Request('GET', 'http://example.com')) extends RequestException { + public function getHandlerContext(): array + { + return ['errno' => 23]; + } + }; + + $this->assertFalse(HttpHandlerError::isConnectionError($exception)); + } + + public function testDetectsResponseTransferExceptionAndResponse() + { + if (!class_exists(ResponseTransferException::class)) { + $this->markTestSkipped('ResponseTransferException is only available in Guzzle 8.'); + } + + $response = new Response(200); + $exception = new ResponseTransferException( + 'test', + new Request('GET', 'http://example.com'), + $response + ); + + $this->assertTrue(HttpHandlerError::isConnectionError($exception)); + $this->assertSame($response, HttpHandlerError::getResponse($exception)); + } + + public function testGenericResponseExceptionIsNotConnectionError() + { + if (!class_exists(ResponseException::class)) { + $this->markTestSkipped('ResponseException is only available in Guzzle 8.'); + } + + $response = new Response(500); + $exception = new ResponseException( + 'test', + new Request('GET', 'http://example.com'), + $response + ); + + $this->assertFalse(HttpHandlerError::isConnectionError($exception)); + $this->assertSame($response, HttpHandlerError::getResponse($exception)); + } +} diff --git a/tests/Retry/V3/RetryMiddlewareTest.php b/tests/Retry/V3/RetryMiddlewareTest.php index 77dc3fd45c..5df20ec631 100644 --- a/tests/Retry/V3/RetryMiddlewareTest.php +++ b/tests/Retry/V3/RetryMiddlewareTest.php @@ -14,7 +14,6 @@ use Aws\Retry\V3\QuotaManager; use Aws\Retry\V3\RetryMiddleware; use Aws\Retry\RateLimiter; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Promise\RejectedPromise; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; @@ -771,71 +770,6 @@ public function testDeciderIgnoresPHPError() } } - public function testDeciderRetriesWhenCurlErrorCodeMatches() - { - if (!extension_loaded('curl')) { - $this->markTestSkipped('Test skipped on no cURL extension'); - } - $decider = RetryMiddleware::createDefaultDecider(); - $command = new Command('foo'); - $request = new Request('GET', 'http://www.example.com'); - $previous = new RequestException( - 'test', - $request, - null, - null, - ['errno' => CURLE_RECV_ERROR] - ); - $err = new AwsException( - 'e', - $command, - ['connection_error' => false], - $previous - ); - $this->assertTrue($decider(0, $command, $err)); - } - - public function testDeciderRetriesForCustomCurlErrors() - { - if (!extension_loaded('curl')) { - $this->markTestSkipped('Test skipped on no cURL extension'); - } - $decider = RetryMiddleware::createDefaultDecider( - ['curl_errors' => [CURLE_BAD_CONTENT_ENCODING]] - ); - $command = new Command('foo'); - $request = new Request('GET', 'http://www.example.com'); - $previous = new RequestException( - 'test', - $request, - null, - null, - ['errno' => CURLE_BAD_CONTENT_ENCODING] - ); - $err = new AwsException( - 'e', - $command, - ['connection_error' => false], - $previous - ); - $this->assertTrue($decider(0, $command, $err)); - - $previous = new RequestException( - 'test', - $request, - null, - null, - ['errno' => CURLE_ABORTED_BY_CALLBACK] - ); - $err = new AwsException( - 'e', - $command, - ['connection_error' => false], - $previous - ); - $this->assertFalse($decider(0, $command, $err)); - } - public static function awsErrorCodeProvider(): array { $command = new Command('foo'); diff --git a/tests/RetryMiddlewareTest.php b/tests/RetryMiddlewareTest.php index 288947e2b7..644be48dc0 100644 --- a/tests/RetryMiddlewareTest.php +++ b/tests/RetryMiddlewareTest.php @@ -9,7 +9,6 @@ use Aws\ResultInterface; use Aws\RetryMiddleware; use GuzzleHttp\ClientInterface; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Promise\RejectedPromise; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; @@ -84,71 +83,6 @@ public function testDeciderIgnoresPHPError() } } - public function testDeciderRetriesWhenCurlErrorCodeMatches() - { - if (!extension_loaded('curl')) { - $this->markTestSkipped('Test skipped on no cURL extension'); - } - $decider = RetryMiddleware::createDefaultDecider(); - $command = new Command('foo'); - $request = new Request('GET', 'http://www.example.com'); - $previous = new RequestException( - 'test', - $request, - null, - null, - ['errno' => CURLE_RECV_ERROR] - ); - $err = new AwsException( - 'e', - $command, - ['connection_error' => false], - $previous - ); - $this->assertTrue($decider(0, $command, $request, null, $err)); - } - - public function testDeciderRetriesForCustomCurlErrors() - { - if (!extension_loaded('curl')) { - $this->markTestSkipped('Test skipped on no cURL extension'); - } - $decider = RetryMiddleware::createDefaultDecider( - 3, - ['curl_errors' => [CURLE_BAD_CONTENT_ENCODING]] - ); - $command = new Command('foo'); - $request = new Request('GET', 'http://www.example.com'); - $previous = new RequestException( - 'test', - $request, - null, - null, - ['errno' => CURLE_BAD_CONTENT_ENCODING] - ); - $err = new AwsException( - 'e', - $command, - ['connection_error' => false], - $previous - ); - $this->assertTrue($decider(0, $command, $request, null, $err)); - $previous = new RequestException( - 'test', - $request, - null, - null, - ['errno' => CURLE_ABORTED_BY_CALLBACK] - ); - $err = new AwsException( - 'e', - $command, - ['connection_error' => false], - $previous - ); - $this->assertFalse($decider(0, $command, $request, null, $err)); - } - public static function awsErrorCodeProvider(): array { $command = new Command('foo'); diff --git a/tests/RetryMiddlewareV2Test.php b/tests/RetryMiddlewareV2Test.php index add10ab550..48ff803c84 100644 --- a/tests/RetryMiddlewareV2Test.php +++ b/tests/RetryMiddlewareV2Test.php @@ -13,7 +13,6 @@ use Aws\Retry\QuotaManager; use Aws\Retry\RateLimiter; use Aws\RetryMiddlewareV2; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Promise\RejectedPromise; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; @@ -515,73 +514,6 @@ public function testDeciderIgnoresPHPError() } } - public function testDeciderRetriesWhenCurlErrorCodeMatches() - { - if (!extension_loaded('curl')) { - $this->markTestSkipped('Test skipped on no cURL extension'); - } - $decider = RetryMiddlewareV2::createDefaultDecider(new QuotaManager()); - $command = new Command('foo'); - $request = new Request('GET', 'http://www.example.com'); - $previous = new RequestException( - 'test', - $request, - null, - null, - ['errno' => CURLE_RECV_ERROR] - ); - $err = new AwsException( - 'e', - $command, - ['connection_error' => false], - $previous - ); - $this->assertTrue($decider(0, $command, $err)); - } - - public function testDeciderRetriesForCustomCurlErrors() - { - if (!extension_loaded('curl')) { - $this->markTestSkipped('Test skipped on no cURL extension'); - } - $decider = RetryMiddlewareV2::createDefaultDecider( - new QuotaManager(), - 3, - ['curl_errors' => [CURLE_BAD_CONTENT_ENCODING]] - ); - $command = new Command('foo'); - $request = new Request('GET', 'http://www.example.com'); - $previous = new RequestException( - 'test', - $request, - null, - null, - ['errno' => CURLE_BAD_CONTENT_ENCODING] - ); - $err = new AwsException( - 'e', - $command, - ['connection_error' => false], - $previous - ); - $this->assertTrue($decider(0, $command, $err)); - - $previous = new RequestException( - 'test', - $request, - null, - null, - ['errno' => CURLE_ABORTED_BY_CALLBACK] - ); - $err = new AwsException( - 'e', - $command, - ['connection_error' => false], - $previous - ); - $this->assertFalse($decider(0, $command, $err)); - } - public static function awsErrorCodeProvider(): array { $command = new Command('foo'); diff --git a/tests/S3/S3ClientTest.php b/tests/S3/S3ClientTest.php index 04a97b6a56..9d9c33671e 100644 --- a/tests/S3/S3ClientTest.php +++ b/tests/S3/S3ClientTest.php @@ -829,11 +829,10 @@ public function testNetworkingErrorsAreRetriedOnIdempotentCommands( array $retrySettings ) { - $networkingError = $this->getMockBuilder(RequestException::class) - ->disableOriginalConstructor() - ->onlyMethods(['getHandlerContext']) - ->getMock(); - $networkingError->method('getHandlerContext')->willReturn([]); + $networkingError = new RequestException( + 'networking error', + new Psr7\Request('GET', 'http://www.example.com') + ); $retries = $retrySettings['max_attempts'] - 1; $client = new S3Client([ @@ -870,11 +869,10 @@ public function testNetworkingErrorsAreNotRetriedOnNonIdempotentCommands($retryS { $this->expectExceptionMessageMatches("/CompleteMultipartUpload/"); $this->expectException(\Aws\S3\Exception\S3Exception::class); - $networkingError = $this->getMockBuilder(RequestException::class) - ->disableOriginalConstructor() - ->onlyMethods(['getHandlerContext']) - ->getMock(); - $networkingError->method('getHandlerContext')->willReturn([]); + $networkingError = new RequestException( + 'networking error', + new Psr7\Request('GET', 'http://www.example.com') + ); $retries = $retrySettings['max_attempts']; $client = new S3Client([ @@ -910,11 +908,10 @@ public function testNetworkingErrorsAreNotRetriedOnNonIdempotentCommands($retryS #[DataProvider('clientRetrySettingsProvider')] public function testErrorsWithUnparseableBodiesCanBeRetried($retrySettings) { - $networkingError = $this->getMockBuilder(RequestException::class) - ->disableOriginalConstructor() - ->onlyMethods(['getHandlerContext']) - ->getMock(); - $networkingError->method('getHandlerContext')->willReturn([]); + $networkingError = new RequestException( + 'networking error', + new Psr7\Request('GET', 'http://www.example.com') + ); $retries = $retrySettings['max_attempts']; $client = new S3Client([ diff --git a/tests/WaiterTest.php b/tests/WaiterTest.php index 18d43d39fe..6fcfed4858 100644 --- a/tests/WaiterTest.php +++ b/tests/WaiterTest.php @@ -27,6 +27,8 @@ #[CoversClass(Waiter::class)] class WaiterTest extends TestCase { + use CreatesGuzzleExceptionsTrait; + use UsesServiceTrait; use MetricsBuilderTestTrait; @@ -525,7 +527,7 @@ public function testWaiterMatcherExpectsAnyError(): void $response = new Response(200, [], $responseBody); return new RejectedPromise([ 'connection_error' => true, - 'exception' => new RequestException( + 'exception' => self::createRequestException( 'Error', $request, $response