Skip to content

Commit 7a08712

Browse files
committed
Added RetryHttpClient
1 parent f1f37a8 commit 7a08712

File tree

4 files changed

+341
-0
lines changed

4 files changed

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