diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index f046415bb9cc9..0b13deed1e601 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -10,6 +10,7 @@ CHANGELOG `cache_clearer`, `filesystem` and `validator` services to private. * Added `TemplateAwareDataCollectorInterface` and `AbstractDataCollector` to simplify custom data collector creation and leverage autoconfiguration * Add `cache.adapter.redis_tag_aware` tag to use `RedisCacheAwareAdapter` + * added `framework.http_client.retry_failing` configuration tree 5.1.0 ----- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 8cb4b2803e758..a150483e516cb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -17,6 +17,7 @@ use Symfony\Bundle\FullStack; use Symfony\Component\Asset\Package; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; @@ -1367,6 +1368,25 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode) ->info('HTTP Client configuration') ->{!class_exists(FullStack::class) && class_exists(HttpClient::class) ? 'canBeDisabled' : 'canBeEnabled'}() ->fixXmlConfig('scoped_client') + ->beforeNormalization() + ->always(function ($config) { + if (empty($config['scoped_clients']) || !\is_array($config['default_options']['retry_failed'] ?? null)) { + return $config; + } + + foreach ($config['scoped_clients'] as &$scopedConfig) { + if (!isset($scopedConfig['retry_failed']) || true === $scopedConfig['retry_failed']) { + $scopedConfig['retry_failed'] = $config['default_options']['retry_failed']; + continue; + } + if (\is_array($scopedConfig['retry_failed'])) { + $scopedConfig['retry_failed'] = $scopedConfig['retry_failed'] + $config['default_options']['retry_failed']; + } + } + + return $config; + }) + ->end() ->children() ->integerNode('max_host_connections') ->info('The maximum number of connections to a single host.') @@ -1452,6 +1472,7 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode) ->variableNode('md5')->end() ->end() ->end() + ->append($this->addHttpClientRetrySection()) ->end() ->end() ->scalarNode('mock_response_factory') @@ -1594,6 +1615,7 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode) ->variableNode('md5')->end() ->end() ->end() + ->append($this->addHttpClientRetrySection()) ->end() ->end() ->end() @@ -1603,6 +1625,50 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode) ; } + private function addHttpClientRetrySection() + { + $root = new NodeBuilder(); + + return $root + ->arrayNode('retry_failed') + ->fixXmlConfig('http_code') + ->canBeEnabled() + ->addDefaultsIfNotSet() + ->beforeNormalization() + ->always(function ($v) { + if (isset($v['backoff_service']) && (isset($v['delay']) || isset($v['multiplier']) || isset($v['max_delay']))) { + throw new \InvalidArgumentException('The "backoff_service" option cannot be used along with the "delay", "multiplier" or "max_delay" options.'); + } + if (isset($v['decider_service']) && (isset($v['http_codes']))) { + throw new \InvalidArgumentException('The "decider_service" option cannot be used along with the "http_codes" options.'); + } + + return $v; + }) + ->end() + ->children() + ->scalarNode('backoff_service')->defaultNull()->info('service id to override the retry backoff')->end() + ->scalarNode('decider_service')->defaultNull()->info('service id to override the retry decider')->end() + ->arrayNode('http_codes') + ->performNoDeepMerging() + ->beforeNormalization() + ->ifArray() + ->then(function ($v) { + return array_filter(array_values($v)); + }) + ->end() + ->prototype('integer')->end() + ->info('A list of HTTP status code that triggers a retry') + ->defaultValue([423, 425, 429, 500, 502, 503, 504, 507, 510]) + ->end() + ->integerNode('max_retries')->defaultValue(3)->min(0)->end() + ->integerNode('delay')->defaultValue(1000)->min(0)->info('Time in ms to delay (or the initial value when multiplier is used)')->end() + ->floatNode('multiplier')->defaultValue(2)->min(1)->info('If greater than 1, delay will grow exponentially for each retry: (delay * (multiple ^ retries))')->end() + ->integerNode('max_delay')->defaultValue(0)->min(0)->info('Max time in ms that a retry should ever be delayed (0 = infinite)')->end() + ->end() + ; + } + private function addMailerSection(ArrayNodeDefinition $rootNode) { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index e375b3c555528..0063514ad61bf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -64,6 +64,7 @@ use Symfony\Component\Form\FormTypeGuesserInterface; use Symfony\Component\Form\FormTypeInterface; use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\RetryableHttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; @@ -1979,7 +1980,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder { $loader->load('http_client.php'); - $container->getDefinition('http_client')->setArguments([$config['default_options'] ?? [], $config['max_host_connections'] ?? 6]); + $options = $config['default_options'] ?? []; + $retryOptions = $options['retry_failed'] ?? ['enabled' => false]; + unset($options['retry_failed']); + $container->getDefinition('http_client')->setArguments([$options, $config['max_host_connections'] ?? 6]); if (!$hasPsr18 = interface_exists(ClientInterface::class)) { $container->removeDefinition('psr18.http_client'); @@ -1990,8 +1994,11 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $container->removeDefinition(HttpClient::class); } - $httpClientId = $this->isConfigEnabled($container, $profilerConfig) ? '.debug.http_client.inner' : 'http_client'; + if ($this->isConfigEnabled($container, $retryOptions)) { + $this->registerHttpClientRetry($retryOptions, 'http_client', $container); + } + $httpClientId = $retryOptions['enabled'] ?? false ? 'http_client.retry.inner' : ($this->isConfigEnabled($container, $profilerConfig) ? '.debug.http_client.inner' : 'http_client'); foreach ($config['scoped_clients'] as $name => $scopeConfig) { if ('http_client' === $name) { throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name)); @@ -1999,6 +2006,8 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder $scope = $scopeConfig['scope'] ?? null; unset($scopeConfig['scope']); + $retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false]; + unset($scopeConfig['retry_failed']); if (null === $scope) { $baseUri = $scopeConfig['base_uri']; @@ -2016,6 +2025,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder ; } + if ($this->isConfigEnabled($container, $retryOptions)) { + $this->registerHttpClientRetry($retryOptions, $name, $container); + } + $container->registerAliasForArgument($name, HttpClientInterface::class); if ($hasPsr18) { @@ -2033,6 +2046,44 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder } } + private function registerHttpClientRetry(array $retryOptions, string $name, ContainerBuilder $container) + { + if (!class_exists(RetryableHttpClient::class)) { + throw new LogicException('Retry failed request support cannot be enabled as version 5.2+ of the HTTP Client component is required.'); + } + + if (null !== $retryOptions['backoff_service']) { + $backoffReference = new Reference($retryOptions['backoff_service']); + } else { + $retryServiceId = $name.'.retry.exponential_backoff'; + $retryDefinition = new ChildDefinition('http_client.retry.abstract_exponential_backoff'); + $retryDefinition + ->replaceArgument(0, $retryOptions['delay']) + ->replaceArgument(1, $retryOptions['multiplier']) + ->replaceArgument(2, $retryOptions['max_delay']); + $container->setDefinition($retryServiceId, $retryDefinition); + + $backoffReference = new Reference($retryServiceId); + } + if (null !== $retryOptions['decider_service']) { + $deciderReference = new Reference($retryOptions['decider_service']); + } else { + $retryServiceId = $name.'.retry.decider'; + $retryDefinition = new ChildDefinition('http_client.retry.abstract_httpstatuscode_decider'); + $retryDefinition + ->replaceArgument(0, $retryOptions['http_codes']); + $container->setDefinition($retryServiceId, $retryDefinition); + + $deciderReference = new Reference($retryServiceId); + } + + $container + ->register($name.'.retry', RetryableHttpClient::class) + ->setDecoratedService($name) + ->setArguments([new Reference($name.'.retry.inner'), $deciderReference, $backoffReference, $retryOptions['max_retries'], new Reference('logger')]) + ->addTag('monolog.logger', ['channel' => 'http_client']); + } + private function registerMailerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) { if (!class_exists(Mailer::class)) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php index 8bc5d9a6a8dd8..447d07a4a1ad9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php @@ -17,6 +17,8 @@ use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttplugClient; use Symfony\Component\HttpClient\Psr18Client; +use Symfony\Component\HttpClient\Retry\ExponentialBackOff; +use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider; use Symfony\Contracts\HttpClient\HttpClientInterface; return static function (ContainerConfigurator $container) { @@ -48,5 +50,19 @@ service(ResponseFactoryInterface::class)->ignoreOnInvalid(), service(StreamFactoryInterface::class)->ignoreOnInvalid(), ]) + + // retry + ->set('http_client.retry.abstract_exponential_backoff', ExponentialBackOff::class) + ->abstract() + ->args([ + abstract_arg('delay ms'), + abstract_arg('multiplier'), + abstract_arg('max delay ms'), + ]) + ->set('http_client.retry.abstract_httpstatuscode_decider', HttpStatusCodeDecider::class) + ->abstract() + ->args([ + abstract_arg('http codes'), + ]) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 3f5c803baaa17..797a97866d429 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -519,6 +519,7 @@ + @@ -535,7 +536,6 @@ - @@ -544,6 +544,7 @@ + @@ -574,6 +575,20 @@ + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/http_client_retry.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/http_client_retry.php new file mode 100644 index 0000000000000..eeb9e45b40fa5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/http_client_retry.php @@ -0,0 +1,23 @@ +loadFromExtension('framework', [ + 'http_client' => [ + 'default_options' => [ + 'retry_failed' => [ + 'backoff_service' => null, + 'decider_service' => null, + 'http_codes' => [429, 500], + 'max_retries' => 2, + 'delay' => 100, + 'multiplier' => 2, + 'max_delay' => 0, + ] + ], + 'scoped_clients' => [ + 'foo' => [ + 'base_uri' => 'http://example.com', + 'retry_failed' => ['multiplier' => 4], + ], + ], + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/http_client_retry.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/http_client_retry.xml new file mode 100644 index 0000000000000..9d475da0b7edd --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/http_client_retry.xml @@ -0,0 +1,25 @@ + + + + + + + + 429 + 500 + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/http_client_retry.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/http_client_retry.yml new file mode 100644 index 0000000000000..8b81f3d1be3bf --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/http_client_retry.yml @@ -0,0 +1,16 @@ +framework: + http_client: + default_options: + retry_failed: + backoff_service: null + decider_service: null + http_codes: [429, 500] + max_retries: 2 + delay: 100 + multiplier: 2 + max_delay: 0 + scoped_clients: + foo: + base_uri: http://example.com + retry_failed: + multiplier: 4 diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index fb02dc52102c9..48d3a497cb623 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -36,11 +36,13 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Loader\ClosureLoader; use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\RetryableHttpClient; use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass; use Symfony\Component\Messenger\Transport\TransportFactory; @@ -1482,6 +1484,23 @@ public function testHttpClientOverrideDefaultOptions() $this->assertSame($expected, $container->getDefinition('foo')->getArgument(2)); } + public function testHttpClientRetry() + { + if (!class_exists(RetryableHttpClient::class)) { + $this->expectException(LogicException::class); + } + $container = $this->createContainerFromFile('http_client_retry'); + + $this->assertSame([429, 500], $container->getDefinition('http_client.retry.decider')->getArgument(0)); + $this->assertSame(100, $container->getDefinition('http_client.retry.exponential_backoff')->getArgument(0)); + $this->assertSame(2, $container->getDefinition('http_client.retry.exponential_backoff')->getArgument(1)); + $this->assertSame(0, $container->getDefinition('http_client.retry.exponential_backoff')->getArgument(2)); + $this->assertSame(2, $container->getDefinition('http_client.retry')->getArgument(3)); + + $this->assertSame(RetryableHttpClient::class, $container->getDefinition('foo.retry')->getClass()); + $this->assertSame(4, $container->getDefinition('foo.retry.exponential_backoff')->getArgument(1)); + } + public function testHttpClientWithQueryParameterKey() { $container = $this->createContainerFromFile('http_client_xml_key'); diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index 8a45eb70c93ab..3ea81aafccc53 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -10,6 +10,7 @@ CHANGELOG * added `MockResponse::getRequestMethod()` and `getRequestUrl()` to allow inspecting which request has been sent * added `EventSourceHttpClient` a Server-Sent events stream implementing the [EventSource specification](https://www.w3.org/TR/eventsource/#eventsource) * added option "extra.curl" to allow setting additional curl options in `CurlHttpClient` + * added `RetryableHttpClient` to automatically retry failed HTTP requests. 5.1.0 ----- diff --git a/src/Symfony/Component/HttpClient/Retry/ExponentialBackOff.php b/src/Symfony/Component/HttpClient/Retry/ExponentialBackOff.php new file mode 100644 index 0000000000000..e8ff6dc5e59e5 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Retry/ExponentialBackOff.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Retry; + +use Symfony\Component\Messenger\Exception\InvalidArgumentException; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * A retry backOff with a constant or exponential retry delay. + * + * For example, if $delayMilliseconds=10000 & $multiplier=1 (default), + * each retry will wait exactly 10 seconds. + * + * But if $delayMilliseconds=10000 & $multiplier=2: + * * Retry 1: 10 second delay + * * Retry 2: 20 second delay (10000 * 2 = 20000) + * * Retry 3: 40 second delay (20000 * 2 = 40000) + * + * @author Ryan Weaver + * @author Jérémy Derussé + */ +final class ExponentialBackOff implements RetryBackOffInterface +{ + private $delayMilliseconds; + private $multiplier; + private $maxDelayMilliseconds; + + /** + * @param int $delayMilliseconds Amount of time to delay (or the initial value when multiplier is used) + * @param float $multiplier Multiplier to apply to the delay each time a retry occurs + * @param int $maxDelayMilliseconds Maximum delay to allow (0 means no maximum) + */ + public function __construct(int $delayMilliseconds = 1000, float $multiplier = 2, int $maxDelayMilliseconds = 0) + { + if ($delayMilliseconds < 0) { + throw new InvalidArgumentException(sprintf('Delay must be greater than or equal to zero: "%s" given.', $delayMilliseconds)); + } + $this->delayMilliseconds = $delayMilliseconds; + + if ($multiplier < 1) { + throw new InvalidArgumentException(sprintf('Multiplier must be greater than zero: "%s" given.', $multiplier)); + } + $this->multiplier = $multiplier; + + if ($maxDelayMilliseconds < 0) { + throw new InvalidArgumentException(sprintf('Max delay must be greater than or equal to zero: "%s" given.', $maxDelayMilliseconds)); + } + $this->maxDelayMilliseconds = $maxDelayMilliseconds; + } + + public function getDelay(int $retryCount, string $requestMethod, string $requestUrl, array $requestOptions, ResponseInterface $partialResponse, \Throwable $throwable = null): int + { + $delay = $this->delayMilliseconds * $this->multiplier ** $retryCount; + + if ($delay > $this->maxDelayMilliseconds && 0 !== $this->maxDelayMilliseconds) { + return $this->maxDelayMilliseconds; + } + + return $delay; + } +} diff --git a/src/Symfony/Component/HttpClient/Retry/HttpStatusCodeDecider.php b/src/Symfony/Component/HttpClient/Retry/HttpStatusCodeDecider.php new file mode 100644 index 0000000000000..9e2b7a68b66d8 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Retry/HttpStatusCodeDecider.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Retry; + +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * Decides to retry the request when HTTP status codes belong to the given list of codes. + * + * @author Jérémy Derussé + */ +final class HttpStatusCodeDecider implements RetryDeciderInterface +{ + private $statusCodes; + + /** + * @param array $statusCodes List of HTTP status codes that trigger a retry + */ + public function __construct(array $statusCodes = [423, 425, 429, 500, 502, 503, 504, 507, 510]) + { + $this->statusCodes = $statusCodes; + } + + public function shouldRetry(string $requestMethod, string $requestUrl, array $requestOptions, ResponseInterface $partialResponse, \Throwable $throwable = null): bool + { + if ($throwable instanceof TransportExceptionInterface) { + return true; + } + + return \in_array($partialResponse->getStatusCode(), $this->statusCodes, true); + } +} diff --git a/src/Symfony/Component/HttpClient/Retry/RetryBackOffInterface.php b/src/Symfony/Component/HttpClient/Retry/RetryBackOffInterface.php new file mode 100644 index 0000000000000..86f2503523820 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Retry/RetryBackOffInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Retry; + +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Jérémy Derussé + */ +interface RetryBackOffInterface +{ + /** + * Returns the time to wait in milliseconds. + */ + public function getDelay(int $retryCount, string $requestMethod, string $requestUrl, array $requestOptions, ResponseInterface $partialResponse, \Throwable $throwable = null): int; +} diff --git a/src/Symfony/Component/HttpClient/Retry/RetryDeciderInterface.php b/src/Symfony/Component/HttpClient/Retry/RetryDeciderInterface.php new file mode 100644 index 0000000000000..d7f9f12a878f8 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Retry/RetryDeciderInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Retry; + +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Jérémy Derussé + */ +interface RetryDeciderInterface +{ + /** + * Returns whether the request should be retried. + */ + public function shouldRetry(string $requestMethod, string $requestUrl, array $requestOptions, ResponseInterface $partialResponse, \Throwable $throwable = null): bool; +} diff --git a/src/Symfony/Component/HttpClient/RetryableHttpClient.php b/src/Symfony/Component/HttpClient/RetryableHttpClient.php new file mode 100644 index 0000000000000..0a86383246aaa --- /dev/null +++ b/src/Symfony/Component/HttpClient/RetryableHttpClient.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\HttpClient\Response\AsyncContext; +use Symfony\Component\HttpClient\Response\AsyncResponse; +use Symfony\Component\HttpClient\Response\MockResponse; +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\Contracts\HttpClient\ChunkInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * Automatically retries failing HTTP requests. + * + * @author Jérémy Derussé + */ +class RetryableHttpClient implements HttpClientInterface +{ + use AsyncDecoratorTrait; + + private $decider; + private $strategy; + private $maxRetries; + private $logger; + + /** + * @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) + { + $this->client = $client; + $this->decider = $decider ?? new HttpStatusCodeDecider(); + $this->strategy = $strategy ?? new ExponentialBackOff(); + $this->maxRetries = $maxRetries; + $this->logger = $logger ?: new NullLogger(); + } + + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $retryCount = 0; + + return new AsyncResponse($this->client, $method, $url, $options, function (ChunkInterface $chunk, AsyncContext $context) use ($method, $url, $options, &$retryCount) { + $exception = null; + try { + if ($chunk->isTimeout() || null !== $chunk->getInformationalStatus()) { + yield $chunk; + + return; + } + + // only retry first chunk + if (!$chunk->isFirst()) { + $context->passthru(); + yield $chunk; + + return; + } + } catch (TransportExceptionInterface $exception) { + // catch TransportExceptionInterface to send it to strategy. + } + + $statusCode = $context->getStatusCode(); + $headers = $context->getHeaders(); + if ($retryCount >= $this->maxRetries || !$this->decider->shouldRetry($method, $url, $options, $partialResponse = new MockResponse($context->getContent(), ['http_code' => $statusCode, 'headers' => $headers]), $exception)) { + $context->passthru(); + yield $chunk; + + return; + } + + $context->setInfo('retry_count', $retryCount); + $context->getResponse()->cancel(); + + $delay = $this->getDelayFromHeader($headers) ?? $this->strategy->getDelay($retryCount, $method, $url, $options, $partialResponse, $exception); + ++$retryCount; + + $this->logger->info('Error returned by the server. Retrying #{retryCount} using {delay} ms delay: '.($exception ? $exception->getMessage() : 'StatusCode: '.$statusCode), [ + 'retryCount' => $retryCount, + 'delay' => $delay, + ]); + + $context->replaceRequest($method, $url, $options); + $context->pause($delay / 1000); + }); + } + + private function getDelayFromHeader(array $headers): ?int + { + if (null !== $after = $headers['retry-after'][0] ?? null) { + if (is_numeric($after)) { + return (int) $after * 1000; + } + if (false !== $time = strtotime($after)) { + return max(0, $time - time()) * 1000; + } + } + + return null; + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php b/src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php new file mode 100644 index 0000000000000..f97572ecc42fc --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php @@ -0,0 +1,54 @@ + + * + * 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\Response\MockResponse; +use Symfony\Component\HttpClient\Retry\ExponentialBackOff; + +class ExponentialBackOffTest extends TestCase +{ + /** + * @dataProvider provideDelay + */ + public function testGetDelay(int $delay, int $multiplier, int $maxDelay, int $previousRetries, int $expectedDelay) + { + $backOff = new ExponentialBackOff($delay, $multiplier, $maxDelay); + + self::assertSame($expectedDelay, $backOff->getDelay($previousRetries, 'GET', 'http://example.com/', [], new MockResponse(), null)); + } + + public function provideDelay(): iterable + { + // delay, multiplier, maxDelay, retries, expectedDelay + yield [1000, 1, 5000, 0, 1000]; + yield [1000, 1, 5000, 1, 1000]; + yield [1000, 1, 5000, 2, 1000]; + + yield [1000, 2, 10000, 0, 1000]; + yield [1000, 2, 10000, 1, 2000]; + yield [1000, 2, 10000, 2, 4000]; + yield [1000, 2, 10000, 3, 8000]; + yield [1000, 2, 10000, 4, 10000]; // max hit + yield [1000, 2, 0, 4, 16000]; // no max + + yield [1000, 3, 10000, 0, 1000]; + yield [1000, 3, 10000, 1, 3000]; + yield [1000, 3, 10000, 2, 9000]; + + yield [1000, 1, 500, 0, 500]; // max hit immediately + + // never a delay + yield [0, 2, 10000, 0, 0]; + yield [0, 2, 10000, 1, 0]; + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/Retry/HttpStatusCodeDeciderTest.php b/src/Symfony/Component/HttpClient/Tests/Retry/HttpStatusCodeDeciderTest.php new file mode 100644 index 0000000000000..3c9a882b02e82 --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/Retry/HttpStatusCodeDeciderTest.php @@ -0,0 +1,41 @@ + + * + * 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\Exception\TransportException; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider; + +class HttpStatusCodeDeciderTest extends TestCase +{ + public function testShouldRetryException() + { + $decider = new HttpStatusCodeDecider([500]); + + self::assertTrue($decider->shouldRetry('GET', 'http://example.com/', [], new MockResponse(), new TransportException())); + } + + public function testShouldRetryStatusCode() + { + $decider = new HttpStatusCodeDecider([500]); + + self::assertTrue($decider->shouldRetry('GET', 'http://example.com/', [], new MockResponse('', ['http_code' => 500]), null)); + } + + public function testIsNotRetryableOk() + { + $decider = new HttpStatusCodeDecider([500]); + + self::assertFalse($decider->shouldRetry('GET', 'http://example.com/', [], new MockResponse(''), null)); + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/RetryableHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/RetryableHttpClientTest.php new file mode 100644 index 0000000000000..c7b67117288cd --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/RetryableHttpClientTest.php @@ -0,0 +1,50 @@ + 500]), + new MockResponse('', ['http_code' => 200]), + ]), + new HttpStatusCodeDecider([500]), + new ExponentialBackOff(0), + 1 + ); + + $response = $client->request('GET', 'http://example.com/foo-bar'); + + self::assertSame(200, $response->getStatusCode()); + } + + public function testRetryRespectStrategy(): void + { + $client = new RetryableHttpClient( + new MockHttpClient([ + new MockResponse('', ['http_code' => 500]), + new MockResponse('', ['http_code' => 500]), + new MockResponse('', ['http_code' => 200]), + ]), + new HttpStatusCodeDecider([500]), + new ExponentialBackOff(0), + 1 + ); + + $response = $client->request('GET', 'http://example.com/foo-bar'); + + $this->expectException(ServerException::class); + $response->getHeaders(); + } +} diff --git a/src/Symfony/Component/Messenger/Retry/MultiplierRetryStrategy.php b/src/Symfony/Component/Messenger/Retry/MultiplierRetryStrategy.php index 8f5c1cdc3f3de..70ae43e9ec92d 100644 --- a/src/Symfony/Component/Messenger/Retry/MultiplierRetryStrategy.php +++ b/src/Symfony/Component/Messenger/Retry/MultiplierRetryStrategy.php @@ -48,17 +48,17 @@ public function __construct(int $maxRetries = 3, int $delayMilliseconds = 1000, $this->maxRetries = $maxRetries; if ($delayMilliseconds < 0) { - throw new InvalidArgumentException(sprintf('Delay must be greater than or equal to zero: "%s" passed.', $delayMilliseconds)); + throw new InvalidArgumentException(sprintf('Delay must be greater than or equal to zero: "%s" given.', $delayMilliseconds)); } $this->delayMilliseconds = $delayMilliseconds; if ($multiplier < 1) { - throw new InvalidArgumentException(sprintf('Multiplier must be greater than zero: "%s" passed.', $multiplier)); + throw new InvalidArgumentException(sprintf('Multiplier must be greater than zero: "%s" given.', $multiplier)); } $this->multiplier = $multiplier; if ($maxDelayMilliseconds < 0) { - throw new InvalidArgumentException(sprintf('Max delay must be greater than or equal to zero: "%s" passed.', $maxDelayMilliseconds)); + throw new InvalidArgumentException(sprintf('Max delay must be greater than or equal to zero: "%s" given.', $maxDelayMilliseconds)); } $this->maxDelayMilliseconds = $maxDelayMilliseconds; }