[HttpClient] Added RetryHttpClient

This commit is contained in:
Jérémy Derussé 2020-09-14 09:45:46 +02:00 committed by Fabien Potencier
parent f1f37a899c
commit 712ac5999d
19 changed files with 662 additions and 6 deletions

View File

@ -10,6 +10,7 @@ CHANGELOG
`cache_clearer`, `filesystem` and `validator` services to private. `cache_clearer`, `filesystem` and `validator` services to private.
* Added `TemplateAwareDataCollectorInterface` and `AbstractDataCollector` to simplify custom data collector creation and leverage autoconfiguration * Added `TemplateAwareDataCollectorInterface` and `AbstractDataCollector` to simplify custom data collector creation and leverage autoconfiguration
* Add `cache.adapter.redis_tag_aware` tag to use `RedisCacheAwareAdapter` * Add `cache.adapter.redis_tag_aware` tag to use `RedisCacheAwareAdapter`
* added `framework.http_client.retry_failing` configuration tree
5.1.0 5.1.0
----- -----

View File

@ -17,6 +17,7 @@ use Doctrine\DBAL\Connection;
use Symfony\Bundle\FullStack; use Symfony\Bundle\FullStack;
use Symfony\Component\Asset\Package; use Symfony\Component\Asset\Package;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; 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\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
@ -1367,6 +1368,25 @@ class Configuration implements ConfigurationInterface
->info('HTTP Client configuration') ->info('HTTP Client configuration')
->{!class_exists(FullStack::class) && class_exists(HttpClient::class) ? 'canBeDisabled' : 'canBeEnabled'}() ->{!class_exists(FullStack::class) && class_exists(HttpClient::class) ? 'canBeDisabled' : 'canBeEnabled'}()
->fixXmlConfig('scoped_client') ->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() ->children()
->integerNode('max_host_connections') ->integerNode('max_host_connections')
->info('The maximum number of connections to a single host.') ->info('The maximum number of connections to a single host.')
@ -1452,6 +1472,7 @@ class Configuration implements ConfigurationInterface
->variableNode('md5')->end() ->variableNode('md5')->end()
->end() ->end()
->end() ->end()
->append($this->addHttpClientRetrySection())
->end() ->end()
->end() ->end()
->scalarNode('mock_response_factory') ->scalarNode('mock_response_factory')
@ -1594,6 +1615,7 @@ class Configuration implements ConfigurationInterface
->variableNode('md5')->end() ->variableNode('md5')->end()
->end() ->end()
->end() ->end()
->append($this->addHttpClientRetrySection())
->end() ->end()
->end() ->end()
->end() ->end()
@ -1603,6 +1625,50 @@ class Configuration implements ConfigurationInterface
; ;
} }
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) private function addMailerSection(ArrayNodeDefinition $rootNode)
{ {
$rootNode $rootNode

View File

@ -64,6 +64,7 @@ use Symfony\Component\Form\FormTypeExtensionInterface;
use Symfony\Component\Form\FormTypeGuesserInterface; use Symfony\Component\Form\FormTypeGuesserInterface;
use Symfony\Component\Form\FormTypeInterface; use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface; use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface;
@ -1979,7 +1980,10 @@ class FrameworkExtension extends Extension
{ {
$loader->load('http_client.php'); $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)) { if (!$hasPsr18 = interface_exists(ClientInterface::class)) {
$container->removeDefinition('psr18.http_client'); $container->removeDefinition('psr18.http_client');
@ -1990,8 +1994,11 @@ class FrameworkExtension extends Extension
$container->removeDefinition(HttpClient::class); $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) { foreach ($config['scoped_clients'] as $name => $scopeConfig) {
if ('http_client' === $name) { if ('http_client' === $name) {
throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name)); throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name));
@ -1999,6 +2006,8 @@ class FrameworkExtension extends Extension
$scope = $scopeConfig['scope'] ?? null; $scope = $scopeConfig['scope'] ?? null;
unset($scopeConfig['scope']); unset($scopeConfig['scope']);
$retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false];
unset($scopeConfig['retry_failed']);
if (null === $scope) { if (null === $scope) {
$baseUri = $scopeConfig['base_uri']; $baseUri = $scopeConfig['base_uri'];
@ -2016,6 +2025,10 @@ class FrameworkExtension extends Extension
; ;
} }
if ($this->isConfigEnabled($container, $retryOptions)) {
$this->registerHttpClientRetry($retryOptions, $name, $container);
}
$container->registerAliasForArgument($name, HttpClientInterface::class); $container->registerAliasForArgument($name, HttpClientInterface::class);
if ($hasPsr18) { if ($hasPsr18) {
@ -2033,6 +2046,44 @@ class FrameworkExtension extends Extension
} }
} }
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) private function registerMailerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader)
{ {
if (!class_exists(Mailer::class)) { if (!class_exists(Mailer::class)) {

View File

@ -17,6 +17,8 @@ use Psr\Http\Message\StreamFactoryInterface;
use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\HttplugClient; use Symfony\Component\HttpClient\HttplugClient;
use Symfony\Component\HttpClient\Psr18Client; use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpClient\Retry\ExponentialBackOff;
use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
return static function (ContainerConfigurator $container) { return static function (ContainerConfigurator $container) {
@ -48,5 +50,19 @@ return static function (ContainerConfigurator $container) {
service(ResponseFactoryInterface::class)->ignoreOnInvalid(), service(ResponseFactoryInterface::class)->ignoreOnInvalid(),
service(StreamFactoryInterface::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'),
])
; ;
}; };

View File

@ -519,6 +519,7 @@
<xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" /> <xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" /> <xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" /> <xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="retry-failed" type="http_client_retry_failed" minOccurs="0" maxOccurs="1" />
</xsd:choice> </xsd:choice>
<xsd:attribute name="max-redirects" type="xsd:integer" /> <xsd:attribute name="max-redirects" type="xsd:integer" />
<xsd:attribute name="http-version" type="xsd:string" /> <xsd:attribute name="http-version" type="xsd:string" />
@ -535,7 +536,6 @@
<xsd:attribute name="local-pk" type="xsd:string" /> <xsd:attribute name="local-pk" type="xsd:string" />
<xsd:attribute name="passphrase" type="xsd:string" /> <xsd:attribute name="passphrase" type="xsd:string" />
<xsd:attribute name="ciphers" type="xsd:string" /> <xsd:attribute name="ciphers" type="xsd:string" />
</xsd:complexType> </xsd:complexType>
<xsd:complexType name="http_client_scope_options" mixed="true"> <xsd:complexType name="http_client_scope_options" mixed="true">
@ -544,6 +544,7 @@
<xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" /> <xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" /> <xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" /> <xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="retry-failed" type="http_client_retry_failed" minOccurs="0" maxOccurs="1" />
</xsd:choice> </xsd:choice>
<xsd:attribute name="name" type="xsd:string" /> <xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="scope" type="xsd:string" /> <xsd:attribute name="scope" type="xsd:string" />
@ -574,6 +575,20 @@
</xsd:choice> </xsd:choice>
</xsd:complexType> </xsd:complexType>
<xsd:complexType name="http_client_retry_failed">
<xsd:sequence>
<xsd:element name="http-code" type="xsd:integer" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:attribute name="enabled" type="xsd:boolean" />
<xsd:attribute name="backoff-service" type="xsd:string" />
<xsd:attribute name="decider-service" type="xsd:string" />
<xsd:attribute name="max-retries" type="xsd:integer" />
<xsd:attribute name="delay" type="xsd:integer" />
<xsd:attribute name="multiplier" type="xsd:float" />
<xsd:attribute name="max-delay" type="xsd:float" />
<xsd:attribute name="response_header" type="xsd:boolean" />
</xsd:complexType>
<xsd:complexType name="http_query" mixed="true"> <xsd:complexType name="http_query" mixed="true">
<xsd:attribute name="key" type="xsd:string" /> <xsd:attribute name="key" type="xsd:string" />
</xsd:complexType> </xsd:complexType>

View File

@ -0,0 +1,23 @@
<?php
$container->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],
],
],
],
]);

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:framework="http://symfony.com/schema/dic/symfony"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd
http://symfony.com/schema/dic/symfony http://symfony.com/schema/dic/symfony/symfony-1.0.xsd">
<framework:config>
<framework:http-client>
<framework:default-options>
<framework:retry-failed
delay="100"
max-delay="0"
max-retries="2"
multiplier="2">
<framework:http-code>429</framework:http-code>
<framework:http-code>500</framework:http-code>
</framework:retry-failed>
</framework:default-options>
<framework:scoped-client name="foo" base-uri="http://example.com">
<framework:retry-failed multiplier="4"/>
</framework:scoped-client>
</framework:http-client>
</framework:config>
</container>

View File

@ -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

View File

@ -36,11 +36,13 @@ use Symfony\Component\DependencyInjection\Compiler\ResolveInstanceofConditionals
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\LogicException;
use Symfony\Component\DependencyInjection\Loader\ClosureLoader; use Symfony\Component\DependencyInjection\Loader\ClosureLoader;
use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag; use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag;
use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\ScopingHttpClient; use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass; use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass;
use Symfony\Component\Messenger\Transport\TransportFactory; use Symfony\Component\Messenger\Transport\TransportFactory;
@ -1482,6 +1484,23 @@ abstract class FrameworkExtensionTest extends TestCase
$this->assertSame($expected, $container->getDefinition('foo')->getArgument(2)); $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() public function testHttpClientWithQueryParameterKey()
{ {
$container = $this->createContainerFromFile('http_client_xml_key'); $container = $this->createContainerFromFile('http_client_xml_key');

View File

@ -10,6 +10,7 @@ CHANGELOG
* added `MockResponse::getRequestMethod()` and `getRequestUrl()` to allow inspecting which request has been sent * 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 `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 option "extra.curl" to allow setting additional curl options in `CurlHttpClient`
* added `RetryableHttpClient` to automatically retry failed HTTP requests.
5.1.0 5.1.0
----- -----

View File

@ -0,0 +1,70 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <ryan@symfonycasts.com>
* @author Jérémy Derussé <jeremy@derusse.com>
*/
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;
}
}

View File

@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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é <jeremy@derusse.com>
*/
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);
}
}

View File

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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é <jeremy@derusse.com>
*/
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;
}

View File

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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é <jeremy@derusse.com>
*/
interface RetryDeciderInterface
{
/**
* Returns whether the request should be retried.
*/
public function shouldRetry(string $requestMethod, string $requestUrl, array $requestOptions, ResponseInterface $partialResponse, \Throwable $throwable = null): bool;
}

View File

@ -0,0 +1,116 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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é <jeremy@derusse.com>
*/
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;
}
}

View File

@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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];
}
}

View File

@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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));
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Symfony\Component\HttpClient\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\Exception\ServerException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\Retry\ExponentialBackOff;
use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider;
use Symfony\Component\HttpClient\RetryableHttpClient;
class RetryableHttpClientTest extends TestCase
{
public function testRetryOnError(): void
{
$client = new RetryableHttpClient(
new MockHttpClient([
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');
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();
}
}

View File

@ -48,17 +48,17 @@ class MultiplierRetryStrategy implements RetryStrategyInterface
$this->maxRetries = $maxRetries; $this->maxRetries = $maxRetries;
if ($delayMilliseconds < 0) { 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; $this->delayMilliseconds = $delayMilliseconds;
if ($multiplier < 1) { 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; $this->multiplier = $multiplier;
if ($maxDelayMilliseconds < 0) { 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; $this->maxDelayMilliseconds = $maxDelayMilliseconds;
} }