*/ -interface RetryDeciderInterface +interface RetryStrategyInterface { /** * Returns whether the request should be retried. @@ -23,5 +27,10 @@ interface RetryDeciderInterface * * @return ?bool Returns null to signal that the body is required to take a decision */ - public function shouldRetry(string $requestMethod, string $requestUrl, array $requestOptions, int $responseStatusCode, array $responseHeaders, ?string $responseContent): ?bool; + public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool; + + /** + * Returns the time to wait in milliseconds. + */ + public function getDelay(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): int; } diff --git a/src/Symfony/Component/HttpClient/RetryableHttpClient.php b/src/Symfony/Component/HttpClient/RetryableHttpClient.php index 1e78393d9d99f..6cf932e60a901 100644 --- a/src/Symfony/Component/HttpClient/RetryableHttpClient.php +++ b/src/Symfony/Component/HttpClient/RetryableHttpClient.php @@ -15,10 +15,8 @@ use Psr\Log\NullLogger; use Symfony\Component\HttpClient\Response\AsyncContext; use Symfony\Component\HttpClient\Response\AsyncResponse; -use Symfony\Component\HttpClient\Retry\ExponentialBackOff; -use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider; -use Symfony\Component\HttpClient\Retry\RetryBackOffInterface; -use Symfony\Component\HttpClient\Retry\RetryDeciderInterface; +use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; +use Symfony\Component\HttpClient\Retry\RetryStrategyInterface; use Symfony\Contracts\HttpClient\ChunkInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -33,7 +31,6 @@ class RetryableHttpClient implements HttpClientInterface { use AsyncDecoratorTrait; - private $decider; private $strategy; private $maxRetries; private $logger; @@ -41,11 +38,10 @@ class RetryableHttpClient implements HttpClientInterface /** * @param int $maxRetries The maximum number of times to retry */ - public function __construct(HttpClientInterface $client, RetryDeciderInterface $decider = null, RetryBackOffInterface $strategy = null, int $maxRetries = 3, LoggerInterface $logger = null) + public function __construct(HttpClientInterface $client, RetryStrategyInterface $strategy = null, int $maxRetries = 3, LoggerInterface $logger = null) { $this->client = $client; - $this->decider = $decider ?? new HttpStatusCodeDecider(); - $this->strategy = $strategy ?? new ExponentialBackOff(); + $this->strategy = $strategy ?? new GenericRetryStrategy(); $this->maxRetries = $maxRetries; $this->logger = $logger ?: new NullLogger(); } @@ -69,23 +65,22 @@ public function request(string $method, string $url, array $options = []): Respo return; } } catch (TransportExceptionInterface $exception) { - // catch TransportExceptionInterface to send it to strategy. + // catch TransportExceptionInterface to send it to the strategy + $context->setInfo('retry_count', $retryCount); } - $statusCode = $context->getStatusCode(); - $headers = $context->getHeaders(); if (null === $exception) { if ($chunk->isFirst()) { - $shouldRetry = $this->decider->shouldRetry($method, $url, $options, $statusCode, $headers, null); + $context->setInfo('retry_count', $retryCount); - if (false === $shouldRetry) { + if (false === $shouldRetry = $this->strategy->shouldRetry($context, null, null)) { $context->passthru(); yield $chunk; return; } - // Decider need body to decide + // Body is needed to decide if (null === $shouldRetry) { $firstChunk = $chunk; $content = ''; @@ -94,12 +89,13 @@ public function request(string $method, string $url, array $options = []): Respo } } else { $content .= $chunk->getContent(); + if (!$chunk->isLast()) { return; } - $shouldRetry = $this->decider->shouldRetry($method, $url, $options, $statusCode, $headers, $content); - if (null === $shouldRetry) { - throw new \LogicException(sprintf('The "%s::shouldRetry" method must not return null when called with a body.', \get_class($this->decider))); + + if (null === $shouldRetry = $this->strategy->shouldRetry($context, $content, null)) { + throw new \LogicException(sprintf('The "%s::shouldRetry()" method must not return null when called with a body.', \get_class($this->strategy))); } if (false === $shouldRetry) { @@ -113,14 +109,13 @@ public function request(string $method, string $url, array $options = []): Respo } } - $context->setInfo('retry_count', $retryCount); $context->getResponse()->cancel(); - $delay = $this->getDelayFromHeader($headers) ?? $this->strategy->getDelay($retryCount, $method, $url, $options, $statusCode, $headers, $chunk instanceof LastChunk ? $content : null, $exception); + $delay = $this->getDelayFromHeader($context->getHeaders()) ?? $this->strategy->getDelay($context, $chunk instanceof LastChunk ? $content : null, $exception); ++$retryCount; - $this->logger->info('Error returned by the server. Retrying #{retryCount} using {delay} ms delay: '.($exception ? $exception->getMessage() : 'StatusCode: '.$statusCode), [ - 'retryCount' => $retryCount, + $this->logger->info('Try #{count} after {delay}ms'.($exception ? ': '.$exception->getMessage() : ', status code: '.$context->getStatusCode()), [ + 'count' => $retryCount, 'delay' => $delay, ]); @@ -139,6 +134,7 @@ private function getDelayFromHeader(array $headers): ?int if (is_numeric($after)) { return (int) $after * 1000; } + if (false !== $time = strtotime($after)) { return max(0, $time - time()) * 1000; } diff --git a/src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php b/src/Symfony/Component/HttpClient/Tests/Retry/GenericRetryStrategyTest.php similarity index 51% rename from src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php rename to src/Symfony/Component/HttpClient/Tests/Retry/GenericRetryStrategyTest.php index 70351df6cbcb9..ecb84cb482ec7 100644 --- a/src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php +++ b/src/Symfony/Component/HttpClient/Tests/Retry/GenericRetryStrategyTest.php @@ -12,18 +12,35 @@ namespace Symfony\Component\HttpClient\Tests\Retry; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpClient\Retry\ExponentialBackOff; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\AsyncContext; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; -class ExponentialBackOffTest extends TestCase +class GenericRetryStrategyTest extends TestCase { + public function testShouldRetryStatusCode() + { + $strategy = new GenericRetryStrategy([500]); + + self::assertTrue($strategy->shouldRetry($this->getContext(0, 'GET', 'http://example.com/', 500), null, null)); + } + + public function testIsNotRetryableOk() + { + $strategy = new GenericRetryStrategy([500]); + + self::assertFalse($strategy->shouldRetry($this->getContext(0, 'GET', 'http://example.com/', 200), null, null)); + } + /** * @dataProvider provideDelay */ public function testGetDelay(int $delay, int $multiplier, int $maxDelay, int $previousRetries, int $expectedDelay) { - $backOff = new ExponentialBackOff($delay, $multiplier, $maxDelay, 0); + $strategy = new GenericRetryStrategy([], $delay, $multiplier, $maxDelay, 0); - self::assertSame($expectedDelay, $backOff->getDelay($previousRetries, 'GET', 'http://example.com/', [], 200, [], null, null)); + self::assertSame($expectedDelay, $strategy->getDelay($this->getContext($previousRetries, 'GET', 'http://example.com/', 200), null, null)); } public function provideDelay(): iterable @@ -53,11 +70,11 @@ public function provideDelay(): iterable public function testJitter() { - $backOff = new ExponentialBackOff(1000, 1, 0, 1); + $strategy = new GenericRetryStrategy([], 1000, 1, 0, 1); $belowHalf = 0; $aboveHalf = 0; for ($i = 0; $i < 20; ++$i) { - $delay = $backOff->getDelay(0, 'GET', 'http://example.com/', [], 200, [], null, null); + $delay = $strategy->getDelay($this->getContext(0, 'GET', 'http://example.com/', 200), null, null); if ($delay < 500) { ++$belowHalf; } elseif ($delay > 1500) { @@ -68,4 +85,18 @@ public function testJitter() $this->assertGreaterThanOrEqual(1, $belowHalf); $this->assertGreaterThanOrEqual(1, $aboveHalf); } + + private function getContext($retryCount, $method, $url, $statusCode): AsyncContext + { + $passthru = null; + $info = [ + 'retry_count' => $retryCount, + 'http_method' => $method, + 'url' => $url, + 'http_code' => $statusCode, + ]; + $response = new MockResponse('', $info); + + return new AsyncContext($passthru, new MockHttpClient(), $response, $info, null, 0); + } } diff --git a/src/Symfony/Component/HttpClient/Tests/Retry/HttpStatusCodeDeciderTest.php b/src/Symfony/Component/HttpClient/Tests/Retry/HttpStatusCodeDeciderTest.php deleted file mode 100644 index d634c15ae6559..0000000000000 --- a/src/Symfony/Component/HttpClient/Tests/Retry/HttpStatusCodeDeciderTest.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\HttpClient\Tests\Retry; - -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider; - -class HttpStatusCodeDeciderTest extends TestCase -{ - public function testShouldRetryStatusCode() - { - $decider = new HttpStatusCodeDecider([500]); - - self::assertTrue($decider->shouldRetry('GET', 'http://example.com/', [], 500, [], null)); - } - - public function testIsNotRetryableOk() - { - $decider = new HttpStatusCodeDecider([500]); - - self::assertFalse($decider->shouldRetry('GET', 'http://example.com/', [], 200, [], null)); - } -} diff --git a/src/Symfony/Component/HttpClient/Tests/RetryableHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/RetryableHttpClientTest.php index 9e502407e8c45..d4649e5665bbd 100644 --- a/src/Symfony/Component/HttpClient/Tests/RetryableHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/RetryableHttpClientTest.php @@ -5,11 +5,11 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\Exception\ServerException; use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\AsyncContext; use Symfony\Component\HttpClient\Response\MockResponse; -use Symfony\Component\HttpClient\Retry\ExponentialBackOff; -use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider; -use Symfony\Component\HttpClient\Retry\RetryDeciderInterface; +use Symfony\Component\HttpClient\Retry\GenericRetryStrategy; use Symfony\Component\HttpClient\RetryableHttpClient; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; class RetryableHttpClientTest extends TestCase { @@ -20,8 +20,7 @@ public function testRetryOnError() new MockResponse('', ['http_code' => 500]), new MockResponse('', ['http_code' => 200]), ]), - new HttpStatusCodeDecider([500]), - new ExponentialBackOff(0), + new GenericRetryStrategy([500], 0), 1 ); @@ -38,8 +37,7 @@ public function testRetryRespectStrategy() new MockResponse('', ['http_code' => 500]), new MockResponse('', ['http_code' => 200]), ]), - new HttpStatusCodeDecider([500]), - new ExponentialBackOff(0), + new GenericRetryStrategy([500], 0), 1 ); @@ -56,13 +54,12 @@ public function testRetryWithBody() new MockResponse('', ['http_code' => 500]), new MockResponse('', ['http_code' => 200]), ]), - new class() implements RetryDeciderInterface { - public function shouldRetry(string $requestMethod, string $requestUrl, array $requestOptions, int $responseCode, array $responseHeaders, ?string $responseContent): ?bool + new class(GenericRetryStrategy::DEFAULT_RETRY_STATUS_CODES, 0) extends GenericRetryStrategy { + public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool { - return null === $responseContent ? null : 200 !== $responseCode; + return null === $responseContent ? null : 200 !== $context->getStatusCode(); } }, - new ExponentialBackOff(0), 1 ); @@ -78,13 +75,12 @@ public function testRetryWithBodyInvalid() new MockResponse('', ['http_code' => 500]), new MockResponse('', ['http_code' => 200]), ]), - new class() implements RetryDeciderInterface { - public function shouldRetry(string $requestMethod, string $requestUrl, array $requestOptions, int $responseCode, array $responseHeaders, ?string $responseContent, \Throwable $throwable = null): ?bool + new class(GenericRetryStrategy::DEFAULT_RETRY_STATUS_CODES, 0) extends GenericRetryStrategy { + public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool { return null; } }, - new ExponentialBackOff(0), 1 ); @@ -100,8 +96,7 @@ public function testStreamNoRetry() new MockHttpClient([ new MockResponse('', ['http_code' => 500]), ]), - new HttpStatusCodeDecider([500]), - new ExponentialBackOff(0), + new GenericRetryStrategy([500], 0), 0 );