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; } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy