From 96b7f02b48a6a3231c1d781c2f50e884d688ada2 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 28 Aug 2026 13:29:32 +0530 Subject: [PATCH] fix(s3): retry refused connections and unresolvable hosts The strategy returned null whenever there was no response, so it only retried failures the service had answered. A refused connection or an unresolvable host was passed straight through, even though the request never reached the service and replaying it cannot duplicate an effect. Seen in production: a Spaces endpoint refused connections for ~35 minutes and every upload failed on the first attempt with CURLE_COULDNT_CONNECT. Other transport failures stay non-retryable, since a reset or a timeout may have been applied server-side. --- src/Storage/Device/S3/RetryStrategy.php | 14 ++++++++-- tests/Storage/Device/S3/RetryStrategyTest.php | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/Storage/Device/S3/RetryStrategy.php b/src/Storage/Device/S3/RetryStrategy.php index fcd9309c..21aa0213 100644 --- a/src/Storage/Device/S3/RetryStrategy.php +++ b/src/Storage/Device/S3/RetryStrategy.php @@ -9,6 +9,8 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Utopia\Client\Decorator\Retry\Strategy; +use Utopia\Client\Exception\ConnectionException; +use Utopia\Client\Exception\DnsException; /** * Retry strategy for transient S3 rate-limiting errors (e.g. SlowDown, @@ -19,6 +21,10 @@ * error code is not retried, while unparseable 429/503 responses fall back to * status-code detection. * + * A refused connection or an unresolvable host is retried too: the request + * never reached the service, so replaying it cannot duplicate an effect. Other + * transport failures are left alone, since the request may have been applied. + * * Waits use exponential backoff with full jitter so a fleet throttled at the * same moment does not retry in lockstep. * @see \Utopia\Tests\Storage\Device\S3\RetryStrategyTest @@ -54,11 +60,15 @@ public function __construct( public function delay(RequestInterface $request, int $attempt, ?ResponseInterface $response, ?ClientExceptionInterface $error): ?float { - if ($attempt > $this->retries || ! $response instanceof ResponseInterface) { + if ($attempt > $this->retries) { return null; } - if (! $this->isTransient($response)) { + $retryable = $response instanceof ResponseInterface + ? $this->isTransient($response) + : $error instanceof ConnectionException || $error instanceof DnsException; + + if (! $retryable) { return null; } diff --git a/tests/Storage/Device/S3/RetryStrategyTest.php b/tests/Storage/Device/S3/RetryStrategyTest.php index 3bd0ef2a..564aac25 100644 --- a/tests/Storage/Device/S3/RetryStrategyTest.php +++ b/tests/Storage/Device/S3/RetryStrategyTest.php @@ -5,6 +5,8 @@ namespace Utopia\Tests\Storage\Device\S3; use PHPUnit\Framework\TestCase; +use Utopia\Client\Exception\ConnectionException; +use Utopia\Client\Exception\DnsException; use Utopia\Client\Exception\NetworkException; use Utopia\Psr7\Request; use Utopia\Psr7\Response; @@ -79,6 +81,7 @@ public function testRetriesAreCapped(): void $this->assertNull($strategy->delay($this->request(), 3, $response, null)); } + /** A reset connection may have been applied server-side, so it stays non-retryable. */ public function testTransportErrorsAreNotRetried(): void { $error = new NetworkException($this->request(), 'Connection reset'); @@ -86,6 +89,30 @@ public function testTransportErrorsAreNotRetried(): void $this->assertNull(new RetryStrategy()->delay($this->request(), 1, null, $error)); } + public function testRefusedConnectionIsRetried(): void + { + $error = new ConnectionException($this->request(), 'Could not connect to server'); + $strategy = new RetryStrategy(delay: 0.5, randomizer: static fn(): float => 1.0); + + $this->assertEqualsWithDelta(0.5, $strategy->delay($this->request(), 1, null, $error), PHP_FLOAT_EPSILON); + } + + public function testUnresolvableHostIsRetried(): void + { + $error = new DnsException($this->request(), 'Could not resolve host'); + $strategy = new RetryStrategy(delay: 0.5, randomizer: static fn(): float => 1.0); + + $this->assertEqualsWithDelta(0.5, $strategy->delay($this->request(), 1, null, $error), PHP_FLOAT_EPSILON); + } + + public function testConnectionRetriesAreCapped(): void + { + $error = new ConnectionException($this->request(), 'Could not connect to server'); + $strategy = new RetryStrategy(retries: 2, delay: 0.5, randomizer: static fn(): float => 1.0); + + $this->assertNull($strategy->delay($this->request(), 3, null, $error)); + } + public function testReadingTheBodyLeavesItReadable(): void { $body = 'SlowDown';