diff --git a/app/Support/RetryHttpConnectionFailures.php b/app/Support/RetryHttpConnectionFailures.php index 1ab42313a0..dbb6409263 100644 --- a/app/Support/RetryHttpConnectionFailures.php +++ b/app/Support/RetryHttpConnectionFailures.php @@ -13,6 +13,8 @@ use Throwable; * Guzzle middleware that sends a request again once after a connection failure. * cURL counts the DNS lookup in the connect timeout, and a lost DNS query waits 5 seconds before the resolver tries again. * Only idempotent methods are retried, because a POST or PATCH can reach the server before the timeout. + * Guzzle also reports a timeout while waiting for the response as a connection failure; that request reached + * the server and already waited the full timeout, so it is not sent again. */ class RetryHttpConnectionFailures { @@ -22,7 +24,7 @@ class RetryHttpConnectionFailures { return function (RequestInterface $request, array $options) use ($handler): PromiseInterface { return $this->send($handler, $request, $options)->otherwise(function (Throwable $exception) use ($handler, $request, $options): PromiseInterface { - if (! $exception instanceof ConnectException || ! in_array($request->getMethod(), self::RETRIED_METHODS, true)) { + if (! $exception instanceof ConnectException || $this->timedOutAfterSending($exception) || ! in_array($request->getMethod(), self::RETRIED_METHODS, true)) { return Create::rejectionFor($exception); } @@ -33,6 +35,13 @@ class RetryHttpConnectionFailures }; } + private function timedOutAfterSending(ConnectException $exception): bool + { + $context = $exception->getHandlerContext(); + + return ($context['errno'] ?? null) === CURLE_OPERATION_TIMEDOUT && ($context['request_size'] ?? 0) > 0; + } + /** * The cURL handler rejects the promise on a connection failure, but other handlers (such as Http::fake()) throw. */ diff --git a/tests/Feature/HttpConnectionRetryTest.php b/tests/Feature/HttpConnectionRetryTest.php index 58543a1387..58dedddf64 100644 --- a/tests/Feature/HttpConnectionRetryTest.php +++ b/tests/Feature/HttpConnectionRetryTest.php @@ -38,3 +38,20 @@ test('a post request is not retried because the server can have received it', fu expect(fn () => Http::post('https://api.example.com/items'))->toThrow(ConnectionException::class) ->and($this->attempts)->toBe(1); }); + +test('a read request is not sent again after it timed out waiting for the response', function () { + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + + expect(fn () => Http::timeout(1)->get("http://{$address}/slow")) + ->toThrow(ConnectionException::class, 'cURL error 28'); + + stream_set_blocking($server, false); + $connections = 0; + while (@stream_socket_accept($server, 0)) { + $connections++; + } + fclose($server); + + expect($connections)->toBe(1); +});