*/ -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 );
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: