From 1bf2d0c9a219fb2f07b4058ecaf832f68d450654 Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Fri, 23 Oct 2026 17:27:03 +0100 Subject: [PATCH 1/3] fix guzzle 8 transfer exceptions --- src/Http/Senders/GuzzleSender.php | 63 ++++++---- tests/Feature/Guzzle8SenderExceptionTest.php | 120 +++++++++++++++++++ 2 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 tests/Feature/Guzzle8SenderExceptionTest.php diff --git a/src/Http/Senders/GuzzleSender.php b/src/Http/Senders/GuzzleSender.php index b3c6870a..15f28b66 100644 --- a/src/Http/Senders/GuzzleSender.php +++ b/src/Http/Senders/GuzzleSender.php @@ -19,6 +19,7 @@ use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\ResponseTransferException; use GuzzleHttp\Exception\TransferException; use Saloon\Exceptions\Request\FatalRequestException; use Saloon\Http\Senders\Factories\GuzzleMultipartBodyFactory; @@ -114,21 +115,14 @@ public function send(PendingRequest $pendingRequest): Response return $this->createResponse($guzzleResponse, $pendingRequest, $request); } catch (ConnectException $exception) { - // ConnectException means a network exception has happened, like Guzzle - // not being able to connect to the host. - throw new FatalRequestException($exception, $pendingRequest); } catch (RequestException $exception) { - // Sometimes, Guzzle will throw a RequestException without a response. This - // means that it was fatal, so we should still throw a fatal request exception. - - $guzzleResponse = method_exists($exception, 'getResponse') ? $exception->getResponse() : null; - - if (is_null($guzzleResponse)) { - throw new FatalRequestException($exception, $pendingRequest); - } + return $this->responseFromRequestException($exception, $pendingRequest, $request); + } catch (TransferException $exception) { + // Guzzle 8: timeouts and network errors before a response (NetworkException, etc.) + // are no longer ConnectException. Matches processPromise() behaviour. - return $this->createResponse($guzzleResponse, $pendingRequest, $request, $exception); + throw new FatalRequestException($exception, $pendingRequest); } } @@ -167,26 +161,43 @@ function (TransferException $guzzleException) use ($pendingRequest, $psrRequest) throw new FatalRequestException($guzzleException, $pendingRequest); } - // Sometimes, Guzzle will throw a RequestException without a response. This - // means that it was fatal, so we should still throw a fatal request exception. + $response = $this->responseFromRequestException($guzzleException, $pendingRequest, $psrRequest); - $guzzleResponse = method_exists($guzzleException, 'getResponse') ? $guzzleException->getResponse() : null; + return ($exception = $response->toException()) ? throw $exception : $response; + } + ); + } - if (is_null($guzzleResponse)) { - throw new FatalRequestException($guzzleException, $pendingRequest); - } + /** + * Build a Saloon response from a Guzzle RequestException, or throw when the transfer was fatal. + * + * @throws FatalRequestException + */ + protected function responseFromRequestException( + RequestException $exception, + PendingRequest $pendingRequest, + RequestInterface $psrRequest, + ): Response { + if ($this->isIncompleteResponseTransfer($exception)) { + throw new FatalRequestException($exception, $pendingRequest); + } - // Otherwise we'll create a response to convert into an exception. - // This will run the exception through the exception handlers - // which allows the user to handle their own exceptions. + $guzzleResponse = $exception->getResponse(); - $response = $this->createResponse($guzzleResponse, $pendingRequest, $psrRequest, $guzzleException); + if ($guzzleResponse === null) { + throw new FatalRequestException($exception, $pendingRequest); + } - // Throw the exception our way + return $this->createResponse($guzzleResponse, $pendingRequest, $psrRequest, $exception); + } - return ($exception = $response->toException()) ? throw $exception : $response; - } - ); + /** + * Guzzle 8 may return a partial response when the body transfer fails (ResponseTransferException). + */ + protected function isIncompleteResponseTransfer(RequestException $exception): bool + { + return class_exists(ResponseTransferException::class) + && $exception instanceof ResponseTransferException; } /** diff --git a/tests/Feature/Guzzle8SenderExceptionTest.php b/tests/Feature/Guzzle8SenderExceptionTest.php new file mode 100644 index 00000000..d4c3fccb --- /dev/null +++ b/tests/Feature/Guzzle8SenderExceptionTest.php @@ -0,0 +1,120 @@ +markTestSkipped('Requires Guzzle 8 (NetworkException).'); + } +}); + +test('guzzle 8 transfer failures are converted to fatal request exceptions', function (callable $makeException) { + $psrRequest = new PsrRequest('GET', 'https://example.test/resource'); + + $connector = new class extends Connector { + public function resolveBaseUrl(): string + { + return 'https://example.test'; + } + }; + + $request = new class extends Request { + protected Method $method = Method::GET; + + public function resolveEndpoint(): string + { + return '/resource'; + } + }; + + $mock = new MockHandler([$makeException($psrRequest)]); + $connector->sender()->getHandlerStack()->setHandler($mock); + + $connector->send($request); +})->throws(FatalRequestException::class)->with([ + 'ConnectException' => fn (PsrRequest $psrRequest) => new ConnectException('connect failed', $psrRequest), + 'NetworkTimeoutException' => fn (PsrRequest $psrRequest) => new NetworkTimeoutException('timed out before headers', $psrRequest), + 'NetworkException' => fn (PsrRequest $psrRequest) => new NetworkException('empty reply', $psrRequest), + 'ResponseTimeoutException' => fn (PsrRequest $psrRequest) => new ResponseTimeoutException( + 'timed out after headers', + $psrRequest, + new PsrResponse(200, [], '{"partial":'), + ), +]); + +test('guzzle 8 transfer failures are converted to fatal request exceptions when using sendAsync', function () { + $psrRequest = new PsrRequest('GET', 'https://example.test/resource'); + + $connector = new class extends Connector { + public function resolveBaseUrl(): string + { + return 'https://example.test'; + } + }; + + $request = new class extends Request { + protected Method $method = Method::GET; + + public function resolveEndpoint(): string + { + return '/resource'; + } + }; + + $mock = new MockHandler([ + new ResponseTimeoutException( + 'timed out after headers', + $psrRequest, + new PsrResponse(200, [], '{"partial":'), + ), + ]); + $connector->sender()->getHandlerStack()->setHandler($mock); + + $connector->sendAsync($request)->wait(); +})->throws(FatalRequestException::class); + +test('guzzle 8 network failures participate in connector retries when using send', function () { + $psrRequest = new PsrRequest('GET', 'https://example.test/resource'); + $makeException = fn () => new NetworkException('empty reply', $psrRequest); + + $connector = new class extends Connector { + public ?int $tries = 3; + + public function resolveBaseUrl(): string + { + return 'https://example.test'; + } + }; + + $request = new class extends Request { + protected Method $method = Method::GET; + + public function resolveEndpoint(): string + { + return '/resource'; + } + }; + + $mock = new MockHandler([$makeException(), $makeException(), $makeException()]); + $connector->sender()->getHandlerStack()->setHandler($mock); + + try { + $connector->send($request); + } catch (FatalRequestException) { + // + } + + expect($mock)->toHaveCount(0); +}); From 8653c9e8b4e3989812c35afce3c84570f1f600bb Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Fri, 23 Oct 2026 17:31:55 +0100 Subject: [PATCH 2/3] fix failing checks --- src/Http/Senders/GuzzleSender.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Http/Senders/GuzzleSender.php b/src/Http/Senders/GuzzleSender.php index 15f28b66..10502f33 100644 --- a/src/Http/Senders/GuzzleSender.php +++ b/src/Http/Senders/GuzzleSender.php @@ -182,7 +182,7 @@ protected function responseFromRequestException( throw new FatalRequestException($exception, $pendingRequest); } - $guzzleResponse = $exception->getResponse(); + $guzzleResponse = $this->guzzleResponseFromRequestException($exception); if ($guzzleResponse === null) { throw new FatalRequestException($exception, $pendingRequest); @@ -191,6 +191,22 @@ protected function responseFromRequestException( return $this->createResponse($guzzleResponse, $pendingRequest, $psrRequest, $exception); } + /** + * Guzzle 7: getResponse() is on RequestException. Guzzle 8: only on ResponseException subclasses. + */ + protected function guzzleResponseFromRequestException(RequestException $exception): ?ResponseInterface + { + if (! class_exists('GuzzleHttp\Exception\ResponseException')) { + return $exception->getResponse(); + } + + if ($exception instanceof \GuzzleHttp\Exception\ResponseException) { + return $exception->getResponse(); + } + + return null; + } + /** * Guzzle 8 may return a partial response when the body transfer fails (ResponseTransferException). */ From 642c21f225289ef168697f89906386eb30937517 Mon Sep 17 00:00:00 2001 From: JonPurvis Date: Fri, 23 Oct 2026 17:36:10 +0100 Subject: [PATCH 3/3] fix failing checks --- src/Http/Senders/GuzzleSender.php | 10 +++++----- tests/Feature/Guzzle8SenderExceptionTest.php | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Http/Senders/GuzzleSender.php b/src/Http/Senders/GuzzleSender.php index 10502f33..8662d90a 100644 --- a/src/Http/Senders/GuzzleSender.php +++ b/src/Http/Senders/GuzzleSender.php @@ -19,8 +19,8 @@ use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\RequestException; -use GuzzleHttp\Exception\ResponseTransferException; use GuzzleHttp\Exception\TransferException; +use GuzzleHttp\Exception\ResponseTransferException; use Saloon\Exceptions\Request\FatalRequestException; use Saloon\Http\Senders\Factories\GuzzleMultipartBodyFactory; @@ -196,15 +196,15 @@ protected function responseFromRequestException( */ protected function guzzleResponseFromRequestException(RequestException $exception): ?ResponseInterface { - if (! class_exists('GuzzleHttp\Exception\ResponseException')) { + if (class_exists('GuzzleHttp\Exception\ResponseException') && $exception instanceof \GuzzleHttp\Exception\ResponseException) { return $exception->getResponse(); } - if ($exception instanceof \GuzzleHttp\Exception\ResponseException) { - return $exception->getResponse(); + if (! method_exists($exception, 'getResponse')) { + return null; } - return null; + return $exception->getResponse(); } /** diff --git a/tests/Feature/Guzzle8SenderExceptionTest.php b/tests/Feature/Guzzle8SenderExceptionTest.php index d4c3fccb..e58a2ec2 100644 --- a/tests/Feature/Guzzle8SenderExceptionTest.php +++ b/tests/Feature/Guzzle8SenderExceptionTest.php @@ -6,10 +6,10 @@ use Saloon\Http\Request; use Saloon\Http\Connector; use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\Psr7\Request as PsrRequest; -use GuzzleHttp\Psr7\Response as PsrResponse; use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\NetworkException; +use GuzzleHttp\Psr7\Request as PsrRequest; +use GuzzleHttp\Psr7\Response as PsrResponse; use GuzzleHttp\Exception\NetworkTimeoutException; use GuzzleHttp\Exception\ResponseTimeoutException; use Saloon\Exceptions\Request\FatalRequestException;