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