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;
}