Skip to content

Commit 92d2a66

Browse files
committed
Added RetryHttpClient
1 parent f1f37a8 commit 92d2a66

File tree

4 files changed

+367
-0
lines changed

4 files changed

+367
-0
lines changed

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 `RetryHttpClient` to automatically retry requests returning a 5xx response or throwing a transport exception.
1314

1415
5.1.0
1516
-----
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Response;
13+
14+
use Psr\Log\LoggerInterface;
15+
use Psr\Log\NullLogger;
16+
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
17+
use Symfony\Component\HttpClient\Exception\TransportException;
18+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
use Symfony\Contracts\HttpClient\ResponseInterface;
21+
22+
/**
23+
* @author Jérémy Derussé <jeremy@derusse.com>
24+
*
25+
* @internal
26+
*/
27+
class RetryResponse implements ResponseInterface
28+
{
29+
private $client;
30+
31+
private $method;
32+
33+
private $url;
34+
35+
private $options;
36+
37+
private $maxTryCount;
38+
39+
private $logger;
40+
41+
private $tryCount = 1;
42+
43+
private $initialized = false;
44+
45+
/**
46+
* @var ResponseInterface
47+
*/
48+
private $inner;
49+
50+
public function __construct(HttpClientInterface $client, string $method, string $url, array $options, LoggerInterface $logger = null, int $maxTryCount = 3)
51+
{
52+
$this->client = $client;
53+
$this->method = $method;
54+
$this->url = $url;
55+
$this->options = $options;
56+
$this->maxTryCount = $maxTryCount;
57+
58+
$this->logger = $logger ?? new NullLogger();
59+
$this->inner = $this->client->request($this->method, $this->url, $this->options);
60+
}
61+
62+
public function getStatusCode(): int
63+
{
64+
if (!$this->initialized) {
65+
$this->initialize();
66+
}
67+
68+
return $this->inner->getStatusCode();
69+
}
70+
71+
public function getHeaders(bool $throw = true): array
72+
{
73+
if (!$this->initialized) {
74+
$this->initialize();
75+
}
76+
77+
return $this->inner->getHeaders($throw);
78+
}
79+
80+
public function getContent(bool $throw = true): string
81+
{
82+
if (!$this->initialized) {
83+
$this->initialize();
84+
}
85+
86+
return $this->inner->getContent($throw);
87+
}
88+
89+
public function toArray(bool $throw = true): array
90+
{
91+
if (!$this->initialized) {
92+
$this->initialize();
93+
}
94+
95+
return $this->inner->toArray($throw);
96+
}
97+
98+
public function cancel(): void
99+
{
100+
$this->initialized = true;
101+
$this->inner->cancel();
102+
}
103+
104+
public function getInfo(string $type = null)
105+
{
106+
if (!$this->initialized) {
107+
$this->initialize();
108+
}
109+
110+
return $this->inner->getInfo($type);
111+
}
112+
113+
public static function stream(iterable $responses, float $timeout = null): \Generator
114+
{
115+
$wrappedResponses = [];
116+
$map = new \SplObjectStorage();
117+
$client = null;
118+
119+
foreach ($responses as $r) {
120+
if (!$r instanceof self) {
121+
throw new \TypeError(sprintf('"%s::stream()" expects parameter 1 to be an iterable of "%s" objects, "%s" given.', static::class, self::class, get_debug_type($r)));
122+
}
123+
124+
if (null === $client) {
125+
$client = $r->client;
126+
} elseif ($r->client !== $client) {
127+
throw new TransportException(sprintf('Cannot stream "%s" objects with many clients.', self::class));
128+
}
129+
}
130+
131+
if (!$client) {
132+
return;
133+
}
134+
135+
$end = null;
136+
if (null !== $timeout) {
137+
$end = microtime(true) + $timeout;
138+
}
139+
140+
while (true) {
141+
foreach ($responses as $r) {
142+
$wrappedResponses[] = $r->inner;
143+
$map[$r->inner] = $r;
144+
}
145+
$subTimeout = null;
146+
if (null !== $end && ($subTimeout = $end - microtime(true)) <= 0) {
147+
foreach ($map as $response) {
148+
yield $response => new ErrorChunk(0, sprintf('Idle timeout reached for "%s".', $response->getInfo('url')));
149+
}
150+
151+
break;
152+
}
153+
154+
foreach ($client->stream($wrappedResponses, $subTimeout) as $response => $chunk) {
155+
/** @var self $r */
156+
$r = $map[$response];
157+
if (!$chunk->isTimeout() && $chunk->isFirst()) {
158+
if ($r->handleRetry()) {
159+
continue 2;
160+
}
161+
}
162+
yield $r => $chunk;
163+
}
164+
165+
break;
166+
}
167+
}
168+
169+
private function initialize(): void
170+
{
171+
while (!$this->initialized) {
172+
$this->handleRetry();
173+
}
174+
}
175+
176+
/**
177+
* @return bool return true when the request have been retried
178+
*
179+
* @throws TransportExceptionInterface
180+
*/
181+
private function handleRetry(): bool
182+
{
183+
if ($this->initialized) {
184+
return false;
185+
}
186+
187+
$handle = function (string $message, array $context): bool {
188+
if (++$this->tryCount <= $this->maxTryCount) {
189+
$this->logger->info($message.' Retry the request {attempt} of {maxAttempts}.', $context + [
190+
'attempt' => $this->tryCount,
191+
'maxAttempts' => $this->maxTryCount,
192+
]);
193+
$this->inner = $this->client->request($this->method, $this->url, $this->options);
194+
195+
return true;
196+
}
197+
198+
$this->logger->error($message.' Stop after {maxAttempts} attempts.', [
199+
'maxAttempts' => $this->maxTryCount,
200+
]);
201+
202+
return false;
203+
};
204+
205+
try {
206+
if (($status = $this->inner->getStatusCode()) >= 500) {
207+
if ($handle('HTTP request failed with status code {statusCode}.', ['statusCode' => $status])) {
208+
return true;
209+
}
210+
}
211+
$this->initialized = true;
212+
213+
return false;
214+
} catch (TransportExceptionInterface $e) {
215+
if ($handle('HTTP request failed with exception {exception}.', ['exception' => $e])) {
216+
return true;
217+
}
218+
219+
$this->initialized = true;
220+
221+
throw $e;
222+
}
223+
}
224+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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 Symfony\Component\HttpClient\Response\ResponseStream;
16+
use Symfony\Component\HttpClient\Response\RetryResponse;
17+
use Symfony\Contracts\HttpClient\HttpClientInterface;
18+
use Symfony\Contracts\HttpClient\ResponseInterface;
19+
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
20+
21+
/**
22+
* Automatically retries the failing requests
23+
*
24+
* @author Jérémy Derussé <jeremy@derusse.com>
25+
*/
26+
class RetryHttpClient implements HttpClientInterface
27+
{
28+
private $decorated;
29+
30+
private $maxTryCount;
31+
32+
private $logger;
33+
34+
public function __construct(HttpClientInterface $decorated, LoggerInterface $logger = null, int $maxTryCount = 3)
35+
{
36+
$this->decorated = $decorated;
37+
$this->maxTryCount = $maxTryCount;
38+
$this->logger = $logger;
39+
}
40+
41+
public function request(string $method, string $url, array $options = []): ResponseInterface
42+
{
43+
return new RetryResponse($this->decorated, $method, $url, $options, $this->logger, $this->maxTryCount);
44+
}
45+
46+
public function stream($responses, float $timeout = null): ResponseStreamInterface
47+
{
48+
if ($responses instanceof RetryResponse) {
49+
$responses = [$responses];
50+
} elseif (!is_iterable($responses)) {
51+
throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of RetryResponse objects, "%s" given.', __METHOD__, get_debug_type($responses)));
52+
}
53+
54+
return new ResponseStream(RetryResponse::stream($responses, $timeout));
55+
}
56+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace Symfony\Component\HttpClient\Tests;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Symfony\Component\HttpClient\Exception\TransportException;
7+
use Symfony\Component\HttpClient\RetryHttpClient;
8+
use Symfony\Contracts\HttpClient\HttpClientInterface;
9+
use Symfony\Contracts\HttpClient\ResponseInterface;
10+
11+
class RetryHttpClientTest extends TestCase
12+
{
13+
public function testRetryOn500Error(): void
14+
{
15+
$inner = $this->createMock(HttpClientInterface::class);
16+
$client = new RetryHttpClient($inner);
17+
18+
$response1 = $this->createMock(ResponseInterface::class);
19+
$response1->expects(self::once())
20+
->method('getStatusCode')
21+
->willReturn(500);
22+
$response2 = $this->createMock(ResponseInterface::class);
23+
$response2->expects(self::once())
24+
->method('getStatusCode')
25+
->willReturn(200);
26+
$response2->expects(self::once())
27+
->method('getContent')
28+
->willReturn('ok');
29+
$inner->expects(self::exactly(2))
30+
->method('request')
31+
->with('GET', 'http://endpoint', [])
32+
->willReturnOnConsecutiveCalls($response1, $response2);
33+
34+
$response = $client->request('GET', 'http://endpoint');
35+
36+
self::assertSame('ok', $response->getContent());
37+
}
38+
39+
public function testRetryOnTransportException(): void
40+
{
41+
$inner = $this->createMock(HttpClientInterface::class);
42+
$client = new RetryHttpClient($inner);
43+
44+
$response1 = $this->createMock(ResponseInterface::class);
45+
$response1->expects(self::once())
46+
->method('getStatusCode')
47+
->willThrowException(new TransportException());
48+
$response2 = $this->createMock(ResponseInterface::class);
49+
$response2->expects(self::once())
50+
->method('getStatusCode')
51+
->willReturn(200);
52+
$response2->expects(self::once())
53+
->method('getContent')
54+
->willReturn('ok');
55+
$inner->expects(self::exactly(2))
56+
->method('request')
57+
->with('GET', 'http://endpoint', [])
58+
->willReturnOnConsecutiveCalls($response1, $response2);
59+
60+
$response = $client->request('GET', 'http://endpoint');
61+
62+
self::assertSame('ok', $response->getContent());
63+
}
64+
65+
public function testNoRetryOn400Error(): void
66+
{
67+
$inner = $this->createMock(HttpClientInterface::class);
68+
$client = new RetryHttpClient($inner);
69+
70+
$response1 = $this->createMock(ResponseInterface::class);
71+
$response1->expects(self::once())
72+
->method('getStatusCode')
73+
->willReturn(400);
74+
$response1->expects(self::once())
75+
->method('getContent')
76+
->willReturn('ok');
77+
$inner->expects(self::exactly(1))
78+
->method('request')
79+
->with('GET', 'http://endpoint', [])
80+
->willReturnOnConsecutiveCalls($response1);
81+
82+
$response = $client->request('GET', 'http://endpoint');
83+
84+
self::assertSame('ok', $response->getContent());
85+
}
86+
}

0 commit comments

Comments
 (0)