Skip to content

Commit 2ebacda

Browse files
jderussefabpot
authored andcommitted
[HttpClient] Added RetryHttpClient
1 parent 097bdd7 commit 2ebacda

9 files changed

+424
-0
lines changed

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

Retry/ExponentialBackOff.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Retry;
13+
14+
use Symfony\Component\Messenger\Exception\InvalidArgumentException;
15+
use Symfony\Contracts\HttpClient\ResponseInterface;
16+
17+
/**
18+
* A retry backOff with a constant or exponential retry delay.
19+
*
20+
* For example, if $delayMilliseconds=10000 & $multiplier=1 (default),
21+
* each retry will wait exactly 10 seconds.
22+
*
23+
* But if $delayMilliseconds=10000 & $multiplier=2:
24+
* * Retry 1: 10 second delay
25+
* * Retry 2: 20 second delay (10000 * 2 = 20000)
26+
* * Retry 3: 40 second delay (20000 * 2 = 40000)
27+
*
28+
* @author Ryan Weaver <[email protected]>
29+
* @author Jérémy Derussé <[email protected]>
30+
*/
31+
final class ExponentialBackOff implements RetryBackOffInterface
32+
{
33+
private $delayMilliseconds;
34+
private $multiplier;
35+
private $maxDelayMilliseconds;
36+
37+
/**
38+
* @param int $delayMilliseconds Amount of time to delay (or the initial value when multiplier is used)
39+
* @param float $multiplier Multiplier to apply to the delay each time a retry occurs
40+
* @param int $maxDelayMilliseconds Maximum delay to allow (0 means no maximum)
41+
*/
42+
public function __construct(int $delayMilliseconds = 1000, float $multiplier = 2, int $maxDelayMilliseconds = 0)
43+
{
44+
if ($delayMilliseconds < 0) {
45+
throw new InvalidArgumentException(sprintf('Delay must be greater than or equal to zero: "%s" given.', $delayMilliseconds));
46+
}
47+
$this->delayMilliseconds = $delayMilliseconds;
48+
49+
if ($multiplier < 1) {
50+
throw new InvalidArgumentException(sprintf('Multiplier must be greater than zero: "%s" given.', $multiplier));
51+
}
52+
$this->multiplier = $multiplier;
53+
54+
if ($maxDelayMilliseconds < 0) {
55+
throw new InvalidArgumentException(sprintf('Max delay must be greater than or equal to zero: "%s" given.', $maxDelayMilliseconds));
56+
}
57+
$this->maxDelayMilliseconds = $maxDelayMilliseconds;
58+
}
59+
60+
public function getDelay(int $retryCount, string $requestMethod, string $requestUrl, array $requestOptions, ResponseInterface $partialResponse, \Throwable $throwable = null): int
61+
{
62+
$delay = $this->delayMilliseconds * $this->multiplier ** $retryCount;
63+
64+
if ($delay > $this->maxDelayMilliseconds && 0 !== $this->maxDelayMilliseconds) {
65+
return $this->maxDelayMilliseconds;
66+
}
67+
68+
return $delay;
69+
}
70+
}

Retry/HttpStatusCodeDecider.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Retry;
13+
14+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
15+
use Symfony\Contracts\HttpClient\ResponseInterface;
16+
17+
/**
18+
* Decides to retry the request when HTTP status codes belong to the given list of codes.
19+
*
20+
* @author Jérémy Derussé <[email protected]>
21+
*/
22+
final class HttpStatusCodeDecider implements RetryDeciderInterface
23+
{
24+
private $statusCodes;
25+
26+
/**
27+
* @param array $statusCodes List of HTTP status codes that trigger a retry
28+
*/
29+
public function __construct(array $statusCodes = [423, 425, 429, 500, 502, 503, 504, 507, 510])
30+
{
31+
$this->statusCodes = $statusCodes;
32+
}
33+
34+
public function shouldRetry(string $requestMethod, string $requestUrl, array $requestOptions, ResponseInterface $partialResponse, \Throwable $throwable = null): bool
35+
{
36+
if ($throwable instanceof TransportExceptionInterface) {
37+
return true;
38+
}
39+
40+
return \in_array($partialResponse->getStatusCode(), $this->statusCodes, true);
41+
}
42+
}

Retry/RetryBackOffInterface.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Retry;
13+
14+
use Symfony\Contracts\HttpClient\ResponseInterface;
15+
16+
/**
17+
* @author Jérémy Derussé <[email protected]>
18+
*/
19+
interface RetryBackOffInterface
20+
{
21+
/**
22+
* Returns the time to wait in milliseconds.
23+
*/
24+
public function getDelay(int $retryCount, string $requestMethod, string $requestUrl, array $requestOptions, ResponseInterface $partialResponse, \Throwable $throwable = null): int;
25+
}

Retry/RetryDeciderInterface.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Retry;
13+
14+
use Symfony\Contracts\HttpClient\ResponseInterface;
15+
16+
/**
17+
* @author Jérémy Derussé <[email protected]>
18+
*/
19+
interface RetryDeciderInterface
20+
{
21+
/**
22+
* Returns whether the request should be retried.
23+
*/
24+
public function shouldRetry(string $requestMethod, string $requestUrl, array $requestOptions, ResponseInterface $partialResponse, \Throwable $throwable = null): bool;
25+
}

RetryableHttpClient.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient;
13+
14+
use Psr\Log\LoggerInterface;
15+
use Psr\Log\NullLogger;
16+
use Symfony\Component\HttpClient\Response\AsyncContext;
17+
use Symfony\Component\HttpClient\Response\AsyncResponse;
18+
use Symfony\Component\HttpClient\Response\MockResponse;
19+
use Symfony\Component\HttpClient\Retry\ExponentialBackOff;
20+
use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider;
21+
use Symfony\Component\HttpClient\Retry\RetryBackOffInterface;
22+
use Symfony\Component\HttpClient\Retry\RetryDeciderInterface;
23+
use Symfony\Contracts\HttpClient\ChunkInterface;
24+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
25+
use Symfony\Contracts\HttpClient\HttpClientInterface;
26+
use Symfony\Contracts\HttpClient\ResponseInterface;
27+
28+
/**
29+
* Automatically retries failing HTTP requests.
30+
*
31+
* @author Jérémy Derussé <[email protected]>
32+
*/
33+
class RetryableHttpClient implements HttpClientInterface
34+
{
35+
use AsyncDecoratorTrait;
36+
37+
private $decider;
38+
private $strategy;
39+
private $maxRetries;
40+
private $logger;
41+
42+
/**
43+
* @param int $maxRetries The maximum number of times to retry
44+
*/
45+
public function __construct(HttpClientInterface $client, RetryDeciderInterface $decider = null, RetryBackOffInterface $strategy = null, int $maxRetries = 3, LoggerInterface $logger = null)
46+
{
47+
$this->client = $client;
48+
$this->decider = $decider ?? new HttpStatusCodeDecider();
49+
$this->strategy = $strategy ?? new ExponentialBackOff();
50+
$this->maxRetries = $maxRetries;
51+
$this->logger = $logger ?: new NullLogger();
52+
}
53+
54+
public function request(string $method, string $url, array $options = []): ResponseInterface
55+
{
56+
$retryCount = 0;
57+
58+
return new AsyncResponse($this->client, $method, $url, $options, function (ChunkInterface $chunk, AsyncContext $context) use ($method, $url, $options, &$retryCount) {
59+
$exception = null;
60+
try {
61+
if ($chunk->isTimeout() || null !== $chunk->getInformationalStatus()) {
62+
yield $chunk;
63+
64+
return;
65+
}
66+
67+
// only retry first chunk
68+
if (!$chunk->isFirst()) {
69+
$context->passthru();
70+
yield $chunk;
71+
72+
return;
73+
}
74+
} catch (TransportExceptionInterface $exception) {
75+
// catch TransportExceptionInterface to send it to strategy.
76+
}
77+
78+
$statusCode = $context->getStatusCode();
79+
$headers = $context->getHeaders();
80+
if ($retryCount >= $this->maxRetries || !$this->decider->shouldRetry($method, $url, $options, $partialResponse = new MockResponse($context->getContent(), ['http_code' => $statusCode, 'headers' => $headers]), $exception)) {
81+
$context->passthru();
82+
yield $chunk;
83+
84+
return;
85+
}
86+
87+
$context->setInfo('retry_count', $retryCount);
88+
$context->getResponse()->cancel();
89+
90+
$delay = $this->getDelayFromHeader($headers) ?? $this->strategy->getDelay($retryCount, $method, $url, $options, $partialResponse, $exception);
91+
++$retryCount;
92+
93+
$this->logger->info('Error returned by the server. Retrying #{retryCount} using {delay} ms delay: '.($exception ? $exception->getMessage() : 'StatusCode: '.$statusCode), [
94+
'retryCount' => $retryCount,
95+
'delay' => $delay,
96+
]);
97+
98+
$context->replaceRequest($method, $url, $options);
99+
$context->pause($delay / 1000);
100+
});
101+
}
102+
103+
private function getDelayFromHeader(array $headers): ?int
104+
{
105+
if (null !== $after = $headers['retry-after'][0] ?? null) {
106+
if (is_numeric($after)) {
107+
return (int) $after * 1000;
108+
}
109+
if (false !== $time = strtotime($after)) {
110+
return max(0, $time - time()) * 1000;
111+
}
112+
}
113+
114+
return null;
115+
}
116+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Tests\Retry;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpClient\Response\MockResponse;
16+
use Symfony\Component\HttpClient\Retry\ExponentialBackOff;
17+
18+
class ExponentialBackOffTest extends TestCase
19+
{
20+
/**
21+
* @dataProvider provideDelay
22+
*/
23+
public function testGetDelay(int $delay, int $multiplier, int $maxDelay, int $previousRetries, int $expectedDelay)
24+
{
25+
$backOff = new ExponentialBackOff($delay, $multiplier, $maxDelay);
26+
27+
self::assertSame($expectedDelay, $backOff->getDelay($previousRetries, 'GET', 'http://example.com/', [], new MockResponse(), null));
28+
}
29+
30+
public function provideDelay(): iterable
31+
{
32+
// delay, multiplier, maxDelay, retries, expectedDelay
33+
yield [1000, 1, 5000, 0, 1000];
34+
yield [1000, 1, 5000, 1, 1000];
35+
yield [1000, 1, 5000, 2, 1000];
36+
37+
yield [1000, 2, 10000, 0, 1000];
38+
yield [1000, 2, 10000, 1, 2000];
39+
yield [1000, 2, 10000, 2, 4000];
40+
yield [1000, 2, 10000, 3, 8000];
41+
yield [1000, 2, 10000, 4, 10000]; // max hit
42+
yield [1000, 2, 0, 4, 16000]; // no max
43+
44+
yield [1000, 3, 10000, 0, 1000];
45+
yield [1000, 3, 10000, 1, 3000];
46+
yield [1000, 3, 10000, 2, 9000];
47+
48+
yield [1000, 1, 500, 0, 500]; // max hit immediately
49+
50+
// never a delay
51+
yield [0, 2, 10000, 0, 0];
52+
yield [0, 2, 10000, 1, 0];
53+
}
54+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Tests\Retry;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\HttpClient\Exception\TransportException;
16+
use Symfony\Component\HttpClient\Response\MockResponse;
17+
use Symfony\Component\HttpClient\Retry\HttpStatusCodeDecider;
18+
19+
class HttpStatusCodeDeciderTest extends TestCase
20+
{
21+
public function testShouldRetryException()
22+
{
23+
$decider = new HttpStatusCodeDecider([500]);
24+
25+
self::assertTrue($decider->shouldRetry('GET', 'http://example.com/', [], new MockResponse(), new TransportException()));
26+
}
27+
28+
public function testShouldRetryStatusCode()
29+
{
30+
$decider = new HttpStatusCodeDecider([500]);
31+
32+
self::assertTrue($decider->shouldRetry('GET', 'http://example.com/', [], new MockResponse('', ['http_code' => 500]), null));
33+
}
34+
35+
public function testIsNotRetryableOk()
36+
{
37+
$decider = new HttpStatusCodeDecider([500]);
38+
39+
self::assertFalse($decider->shouldRetry('GET', 'http://example.com/', [], new MockResponse(''), null));
40+
}
41+
}

0 commit comments

Comments
 (0)