fix(http): stop retrying requests that timed out after they were sent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-10-28 14:16:22 +01:00
1 parent 597a878d80
commit fd5efd0273
2 files changed
+27 -1

No files matched your search

+10 -1
View File
@@ -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.
*/
+17
View File
@@ -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);
});