Skip to content

Commit 712ac59

Browse files
jderussefabpot
authored andcommitted
[HttpClient] Added RetryHttpClient
1 parent f1f37a8 commit 712ac59

19 files changed

+662
-6
lines changed

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ CHANGELOG
1010
`cache_clearer`, `filesystem` and `validator` services to private.
1111
* Added `TemplateAwareDataCollectorInterface` and `AbstractDataCollector` to simplify custom data collector creation and leverage autoconfiguration
1212
* Add `cache.adapter.redis_tag_aware` tag to use `RedisCacheAwareAdapter`
13+
* added `framework.http_client.retry_failing` configuration tree
1314

1415
5.1.0
1516
-----

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Bundle\FullStack;
1818
use Symfony\Component\Asset\Package;
1919
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
20+
use Symfony\Component\Config\Definition\Builder\NodeBuilder;
2021
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
2122
use Symfony\Component\Config\Definition\ConfigurationInterface;
2223
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
@@ -1367,6 +1368,25 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode)
13671368
->info('HTTP Client configuration')
13681369
->{!class_exists(FullStack::class) && class_exists(HttpClient::class) ? 'canBeDisabled' : 'canBeEnabled'}()
13691370
->fixXmlConfig('scoped_client')
1371+
->beforeNormalization()
1372+
->always(function ($config) {
1373+
if (empty($config['scoped_clients']) || !\is_array($config['default_options']['retry_failed'] ?? null)) {
1374+
return $config;
1375+
}
1376+
1377+
foreach ($config['scoped_clients'] as &$scopedConfig) {
1378+
if (!isset($scopedConfig['retry_failed']) || true === $scopedConfig['retry_failed']) {
1379+
$scopedConfig['retry_failed'] = $config['default_options']['retry_failed'];
1380+
continue;
1381+
}
1382+
if (\is_array($scopedConfig['retry_failed'])) {
1383+
$scopedConfig['retry_failed'] = $scopedConfig['retry_failed'] + $config['default_options']['retry_failed'];
1384+
}
1385+
}
1386+
1387+
return $config;
1388+
})
1389+
->end()
13701390
->children()
13711391
->integerNode('max_host_connections')
13721392
->info('The maximum number of connections to a single host.')
@@ -1452,6 +1472,7 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode)
14521472
->variableNode('md5')->end()
14531473
->end()
14541474
->end()
1475+
->append($this->addHttpClientRetrySection())
14551476
->end()
14561477
->end()
14571478
->scalarNode('mock_response_factory')
@@ -1594,6 +1615,7 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode)
15941615
->variableNode('md5')->end()
15951616
->end()
15961617
->end()
1618+
->append($this->addHttpClientRetrySection())
15971619
->end()
15981620
->end()
15991621
->end()
@@ -1603,6 +1625,50 @@ private function addHttpClientSection(ArrayNodeDefinition $rootNode)
16031625
;
16041626
}
16051627

1628+
private function addHttpClientRetrySection()
1629+
{
1630+
$root = new NodeBuilder();
1631+
1632+
return $root
1633+
->arrayNode('retry_failed')
1634+
->fixXmlConfig('http_code')
1635+
->canBeEnabled()
1636+
->addDefaultsIfNotSet()
1637+
->beforeNormalization()
1638+
->always(function ($v) {
1639+
if (isset($v['backoff_service']) && (isset($v['delay']) || isset($v['multiplier']) || isset($v['max_delay']))) {
1640+
throw new \InvalidArgumentException('The "backoff_service" option cannot be used along with the "delay", "multiplier" or "max_delay" options.');
1641+
}
1642+
if (isset($v['decider_service']) && (isset($v['http_codes']))) {
1643+
throw new \InvalidArgumentException('The "decider_service" option cannot be used along with the "http_codes" options.');
1644+
}
1645+
1646+
return $v;
1647+
})
1648+
->end()
1649+
->children()
1650+
->scalarNode('backoff_service')->defaultNull()->info('service id to override the retry backoff')->end()
1651+
->scalarNode('decider_service')->defaultNull()->info('service id to override the retry decider')->end()
1652+
->arrayNode('http_codes')
1653+
->performNoDeepMerging()
1654+
->beforeNormalization()
1655+
->ifArray()
1656+
->then(function ($v) {
1657+
return array_filter(array_values($v));
1658+
})
1659+
->end()
1660+
->prototype('integer')->end()
1661+
->info('A list of HTTP status code that triggers a retry')
1662+
->defaultValue([423, 425, 429, 500, 502, 503, 504, 507, 510])
1663+
->end()
1664+
->integerNode('max_retries')->defaultValue(3)->min(0)->end()
1665+
->integerNode('delay')->defaultValue(1000)->min(0)->info('Time in ms to delay (or the initial value when multiplier is used)')->end()
1666+
->floatNode('multiplier')->defaultValue(2)->min(1)->info('If greater than 1, delay will grow exponentially for each retry: (delay * (multiple ^ retries))')->end()
1667+
->integerNode('max_delay')->defaultValue(0)->min(0)->info('Max time in ms that a retry should ever be delayed (0 = infinite)')->end()
1668+
->end()
1669+
;
1670+
}
1671+
16061672
private function addMailerSection(ArrayNodeDefinition $rootNode)
16071673
{
16081674
$rootNode

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
use Symfony\Component\Form\FormTypeGuesserInterface;
6565
use Symfony\Component\Form\FormTypeInterface;
6666
use Symfony\Component\HttpClient\MockHttpClient;
67+
use Symfony\Component\HttpClient\RetryableHttpClient;
6768
use Symfony\Component\HttpClient\ScopingHttpClient;
6869
use Symfony\Component\HttpFoundation\Request;
6970
use Symfony\Component\HttpKernel\CacheClearer\CacheClearerInterface;
@@ -1979,7 +1980,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
19791980
{
19801981
$loader->load('http_client.php');
19811982

1982-
$container->getDefinition('http_client')->setArguments([$config['default_options'] ?? [], $config['max_host_connections'] ?? 6]);
1983+
$options = $config['default_options'] ?? [];
1984+
$retryOptions = $options['retry_failed'] ?? ['enabled' => false];
1985+
unset($options['retry_failed']);
1986+
$container->getDefinition('http_client')->setArguments([$options, $config['max_host_connections'] ?? 6]);
19831987

19841988
if (!$hasPsr18 = interface_exists(ClientInterface::class)) {
19851989
$container->removeDefinition('psr18.http_client');
@@ -1990,15 +1994,20 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
19901994
$container->removeDefinition(HttpClient::class);
19911995
}
19921996

1993-
$httpClientId = $this->isConfigEnabled($container, $profilerConfig) ? '.debug.http_client.inner' : 'http_client';
1997+
if ($this->isConfigEnabled($container, $retryOptions)) {
1998+
$this->registerHttpClientRetry($retryOptions, 'http_client', $container);
1999+
}
19942000

2001+
$httpClientId = $retryOptions['enabled'] ?? false ? 'http_client.retry.inner' : ($this->isConfigEnabled($container, $profilerConfig) ? '.debug.http_client.inner' : 'http_client');
19952002
foreach ($config['scoped_clients'] as $name => $scopeConfig) {
19962003
if ('http_client' === $name) {
19972004
throw new InvalidArgumentException(sprintf('Invalid scope name: "%s" is reserved.', $name));
19982005
}
19992006

20002007
$scope = $scopeConfig['scope'] ?? null;
20012008
unset($scopeConfig['scope']);
2009+
$retryOptions = $scopeConfig['retry_failed'] ?? ['enabled' => false];
2010+
unset($scopeConfig['retry_failed']);
20022011

20032012
if (null === $scope) {
20042013
$baseUri = $scopeConfig['base_uri'];
@@ -2016,6 +2025,10 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
20162025
;
20172026
}
20182027

2028+
if ($this->isConfigEnabled($container, $retryOptions)) {
2029+
$this->registerHttpClientRetry($retryOptions, $name, $container);
2030+
}
2031+
20192032
$container->registerAliasForArgument($name, HttpClientInterface::class);
20202033

20212034
if ($hasPsr18) {
@@ -2033,6 +2046,44 @@ private function registerHttpClientConfiguration(array $config, ContainerBuilder
20332046
}
20342047
}
20352048

2049+
private function registerHttpClientRetry(array $retryOptions, string $name, ContainerBuilder $container)
2050+
{
2051+
if (!class_exists(RetryableHttpClient::class)) {
2052+
throw new LogicException('Retry failed request support cannot be enabled as version 5.2+ of the HTTP Client component is required.');
2053+
}
2054+
2055+
if (null !== $retryOptions['backoff_service']) {
2056+
$backoffReference = new Reference($retryOptions['backoff_service']);
2057+
} else {
2058+
$retryServiceId = $name.'.retry.exponential_backoff';
2059+
$retryDefinition = new ChildDefinition('http_client.retry.abstract_exponential_backoff');
2060+
$retryDefinition
2061+
->replaceArgument(0, $retryOptions['delay'])
2062+
->replaceArgument(1, $retryOptions['multiplier'])
2063+
->replaceArgument(2, $retryOptions['max_delay']);
2064+
$container->setDefinition($retryServiceId, $retryDefinition);
2065+
2066+
$backoffReference = new Reference($retryServiceId);
2067+
}
2068+
if (null !== $retryOptions['decider_service']) {
2069+
$deciderReference = new Reference($retryOptions['decider_service']);
2070+
} else {
2071+
$retryServiceId = $name.'.retry.decider';
2072+
$retryDefinition = new ChildDefinition('http_client.retry.abstract_httpstatuscode_decider');
2073+
$retryDefinition
2074+
->replaceArgument(0, $retryOptions['http_codes']);
2075+
$container->setDefinition($retryServiceId, $retryDefinition);
2076+
2077+
$deciderReference = new Reference($retryServiceId);
2078+
}
2079+
2080+
$container
2081+
->register($name.'.retry', RetryableHttpClient::class)
2082+
->setDecoratedService($name)
2083+
->setArguments([new Reference($name.'.retry.inner'), $deciderReference, $backoffReference, $retryOptions['max_retries'], new Reference('logger')])
2084+
->addTag('monolog.logger', ['channel' => 'http_client']);
2085+
}
2086+
20362087
private function registerMailerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader)
20372088
{
20382089
if (!class_exists(Mailer::class)) {

src/Symfony/Bundle/FrameworkBundle/Resources/config/http_client.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
use Symfony\Component\HttpClient\HttpClient;
1818
use Symfony\Component\HttpClient\HttplugClient;
1919
use Symfony\Component\HttpClient\Psr18Client;
20+
use Symfony\Component\HttpClient\Retry\ExponentialBackOff;
21+
use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider;
2022
use Symfony\Contracts\HttpClient\HttpClientInterface;
2123

2224
return static function (ContainerConfigurator $container) {
@@ -48,5 +50,19 @@
4850
service(ResponseFactoryInterface::class)->ignoreOnInvalid(),
4951
service(StreamFactoryInterface::class)->ignoreOnInvalid(),
5052
])
53+
54+
// retry
55+
->set('http_client.retry.abstract_exponential_backoff', ExponentialBackOff::class)
56+
->abstract()
57+
->args([
58+
abstract_arg('delay ms'),
59+
abstract_arg('multiplier'),
60+
abstract_arg('max delay ms'),
61+
])
62+
->set('http_client.retry.abstract_httpstatuscode_decider', HttpStatusCodeDecider::class)
63+
->abstract()
64+
->args([
65+
abstract_arg('http codes'),
66+
])
5167
;
5268
};

src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@
519519
<xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" />
520520
<xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" />
521521
<xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
522+
<xsd:element name="retry-failed" type="http_client_retry_failed" minOccurs="0" maxOccurs="1" />
522523
</xsd:choice>
523524
<xsd:attribute name="max-redirects" type="xsd:integer" />
524525
<xsd:attribute name="http-version" type="xsd:string" />
@@ -535,7 +536,6 @@
535536
<xsd:attribute name="local-pk" type="xsd:string" />
536537
<xsd:attribute name="passphrase" type="xsd:string" />
537538
<xsd:attribute name="ciphers" type="xsd:string" />
538-
539539
</xsd:complexType>
540540

541541
<xsd:complexType name="http_client_scope_options" mixed="true">
@@ -544,6 +544,7 @@
544544
<xsd:element name="resolve" type="http_resolve" minOccurs="0" maxOccurs="unbounded" />
545545
<xsd:element name="header" type="http_header" minOccurs="0" maxOccurs="unbounded" />
546546
<xsd:element name="peer-fingerprint" type="fingerprint" minOccurs="0" maxOccurs="unbounded" />
547+
<xsd:element name="retry-failed" type="http_client_retry_failed" minOccurs="0" maxOccurs="1" />
547548
</xsd:choice>
548549
<xsd:attribute name="name" type="xsd:string" />
549550
<xsd:attribute name="scope" type="xsd:string" />
@@ -574,6 +575,20 @@
574575
</xsd:choice>
575576
</xsd:complexType>
576577

578+
<xsd:complexType name="http_client_retry_failed">
579+
<xsd:sequence>
580+
<xsd:element name="http-code" type="xsd:integer" minOccurs="0" maxOccurs="unbounded" />
581+
</xsd:sequence>
582+
<xsd:attribute name="enabled" type="xsd:boolean" />
583+
<xsd:attribute name="backoff-service" type="xsd:string" />
584+
<xsd:attribute name="decider-service" type="xsd:string" />
585+
<xsd:attribute name="max-retries" type="xsd:integer" />
586+
<xsd:attribute name="delay" type="xsd:integer" />
587+
<xsd:attribute name="multiplier" type="xsd:float" />
588+
<xsd:attribute name="max-delay" type="xsd:float" />
589+
<xsd:attribute name="response_header" type="xsd:boolean" />
590+
</xsd:complexType>
591+
577592
<xsd:complexType name="http_query" mixed="true">
578593
<xsd:attribute name="key" type="xsd:string" />
579594
</xsd:complexType>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
$container->loadFromExtension('framework', [
4+
'http_client' => [
5+
'default_options' => [
6+
'retry_failed' => [
7+
'backoff_service' => null,
8+
'decider_service' => null,
9+
'http_codes' => [429, 500],
10+
'max_retries' => 2,
11+
'delay' => 100,
12+
'multiplier' => 2,
13+
'max_delay' => 0,
14+
]
15+
],
16+
'scoped_clients' => [
17+
'foo' => [
18+
'base_uri' => 'http://example.com',
19+
'retry_failed' => ['multiplier' => 4],
20+
],
21+
],
22+
],
23+
]);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<container xmlns="http://symfony.com/schema/dic/services"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xmlns:framework="http://symfony.com/schema/dic/symfony"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd
6+
http://symfony.com/schema/dic/symfony http://symfony.com/schema/dic/symfony/symfony-1.0.xsd">
7+
8+
<framework:config>
9+
<framework:http-client>
10+
<framework:default-options>
11+
<framework:retry-failed
12+
delay="100"
13+
max-delay="0"
14+
max-retries="2"
15+
multiplier="2">
16+
<framework:http-code>429</framework:http-code>
17+
<framework:http-code>500</framework:http-code>
18+
</framework:retry-failed>
19+
</framework:default-options>
20+
<framework:scoped-client name="foo" base-uri="http://example.com">
21+
<framework:retry-failed multiplier="4"/>
22+
</framework:scoped-client>
23+
</framework:http-client>
24+
</framework:config>
25+
</container>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
framework:
2+
http_client:
3+
default_options:
4+
retry_failed:
5+
backoff_service: null
6+
decider_service: null
7+
http_codes: [429, 500]
8+
max_retries: 2
9+
delay: 100
10+
multiplier: 2
11+
max_delay: 0
12+
scoped_clients:
13+
foo:
14+
base_uri: http://example.com
15+
retry_failed:
16+
multiplier: 4

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@
3636
use Symfony\Component\DependencyInjection\ContainerBuilder;
3737
use Symfony\Component\DependencyInjection\ContainerInterface;
3838
use Symfony\Component\DependencyInjection\Definition;
39+
use Symfony\Component\DependencyInjection\Exception\LogicException;
3940
use Symfony\Component\DependencyInjection\Loader\ClosureLoader;
4041
use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag;
4142
use Symfony\Component\DependencyInjection\Reference;
4243
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
4344
use Symfony\Component\HttpClient\MockHttpClient;
45+
use Symfony\Component\HttpClient\RetryableHttpClient;
4446
use Symfony\Component\HttpClient\ScopingHttpClient;
4547
use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass;
4648
use Symfony\Component\Messenger\Transport\TransportFactory;
@@ -1482,6 +1484,23 @@ public function testHttpClientOverrideDefaultOptions()
14821484
$this->assertSame($expected, $container->getDefinition('foo')->getArgument(2));
14831485
}
14841486

1487+
public function testHttpClientRetry()
1488+
{
1489+
if (!class_exists(RetryableHttpClient::class)) {
1490+
$this->expectException(LogicException::class);
1491+
}
1492+
$container = $this->createContainerFromFile('http_client_retry');
1493+
1494+
$this->assertSame([429, 500], $container->getDefinition('http_client.retry.decider')->getArgument(0));
1495+
$this->assertSame(100, $container->getDefinition('http_client.retry.exponential_backoff')->getArgument(0));
1496+
$this->assertSame(2, $container->getDefinition('http_client.retry.exponential_backoff')->getArgument(1));
1497+
$this->assertSame(0, $container->getDefinition('http_client.retry.exponential_backoff')->getArgument(2));
1498+
$this->assertSame(2, $container->getDefinition('http_client.retry')->getArgument(3));
1499+
1500+
$this->assertSame(RetryableHttpClient::class, $container->getDefinition('foo.retry')->getClass());
1501+
$this->assertSame(4, $container->getDefinition('foo.retry.exponential_backoff')->getArgument(1));
1502+
}
1503+
14851504
public function testHttpClientWithQueryParameterKey()
14861505
{
14871506
$container = $this->createContainerFromFile('http_client_xml_key');

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ CHANGELOG
1010
* added `MockResponse::getRequestMethod()` and `getRequestUrl()` to allow inspecting which request has been sent
1111
* added `EventSourceHttpClient` a Server-Sent events stream implementing the [EventSource specification](https://www.w3.org/TR/eventsource/#eventsource)
1212
* added option "extra.curl" to allow setting additional curl options in `CurlHttpClient`
13+
* added `RetryableHttpClient` to automatically retry failed HTTP requests.
1314

1415
5.1.0
1516
-----

0 commit comments

Comments
 (0)
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