Skip to content

Commit f685ec6

Browse files
authored
Merge pull request #45 from sunrise-php/release/v2.1.0
v2.1
2 parents 3c17ee2 + b1e0efb commit f685ec6

3 files changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Psr\Container\ContainerInterface;
6+
use Psr\Http\Client\ClientInterface;
7+
use Sunrise\Http\Client\Curl\Decorator\RetryableClient;
8+
9+
use function DI\decorate;
10+
11+
return [
12+
'retryable_http_client.max_attempts' => 3,
13+
'retryable_http_client.base_delay' => 250_000,
14+
15+
ClientInterface::class => decorate(
16+
static function (ClientInterface $previous, ContainerInterface $container): ClientInterface {
17+
return new RetryableClient(
18+
baseClient: $previous,
19+
maxAttempts: $container->get('retryable_http_client.max_attempts'),
20+
baseDelay: $container->get('retryable_http_client.base_delay'),
21+
);
22+
}
23+
),
24+
];

src/Decorator/RetryableClient.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
/**
4+
* It's free open-source software released under the MIT License.
5+
*
6+
* @author Anatoly Nekhay <[email protected]>
7+
* @copyright Copyright (c) 2018, Anatoly Nekhay
8+
* @license https://github.com/sunrise-php/http-client-curl/blob/master/LICENSE
9+
* @link https://github.com/sunrise-php/http-client-curl
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Sunrise\Http\Client\Curl\Decorator;
15+
16+
use InvalidArgumentException;
17+
use Psr\Http\Client\ClientInterface;
18+
use Psr\Http\Client\NetworkExceptionInterface;
19+
use Psr\Http\Message\RequestInterface;
20+
use Psr\Http\Message\ResponseInterface;
21+
22+
use function random_int;
23+
use function usleep;
24+
25+
/**
26+
* @since 2.1.0
27+
*/
28+
final class RetryableClient implements ClientInterface
29+
{
30+
public function __construct(
31+
private readonly ClientInterface $baseClient,
32+
private readonly int $maxAttempts,
33+
private readonly int $baseDelay,
34+
) {
35+
if ($maxAttempts < 1) {
36+
throw new InvalidArgumentException('maxAttempts must be >= 1');
37+
}
38+
if ($baseDelay < 0) {
39+
throw new InvalidArgumentException('baseDelay must be >= 0');
40+
}
41+
}
42+
43+
/**
44+
* @inheritDoc
45+
*/
46+
public function sendRequest(RequestInterface $request): ResponseInterface
47+
{
48+
$attempt = 0;
49+
while (true) {
50+
$attempt++;
51+
52+
try {
53+
return $this->baseClient->sendRequest($request);
54+
} catch (NetworkExceptionInterface $e) {
55+
$attempt < $this->maxAttempts ? $this->applyDelay($attempt) : throw $e;
56+
}
57+
}
58+
}
59+
60+
private function applyDelay(int $attempt): void
61+
{
62+
usleep($this->calculateDelay($attempt));
63+
}
64+
65+
/**
66+
* @link https://aws.amazon.com/ru/blogs/architecture/exponential-backoff-and-jitter/
67+
*/
68+
private function calculateDelay(int $attempt): int
69+
{
70+
// full jitter
71+
return random_int(0, $this->baseDelay * (2 ** ($attempt - 1)));
72+
}
73+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sunrise\Http\Client\Curl\Tests\Decorator;
6+
7+
use InvalidArgumentException;
8+
use PHPUnit\Framework\MockObject\MockObject;
9+
use PHPUnit\Framework\TestCase;
10+
use Psr\Http\Client\ClientInterface;
11+
use Psr\Http\Client\NetworkExceptionInterface;
12+
use Psr\Http\Message\RequestInterface;
13+
use Psr\Http\Message\ResponseInterface;
14+
use Sunrise\Http\Client\Curl\Decorator\RetryableClient;
15+
16+
final class RetryableClientTest extends TestCase
17+
{
18+
private ClientInterface&MockObject $baseClient;
19+
private RequestInterface&MockObject $testRequest;
20+
private ResponseInterface&MockObject $testResponse;
21+
private NetworkExceptionInterface&MockObject $networkException;
22+
private int $sendAttempt;
23+
24+
protected function setUp(): void
25+
{
26+
$this->baseClient = $this->createMock(ClientInterface::class);
27+
$this->testRequest = $this->createMock(RequestInterface::class);
28+
$this->testResponse = $this->createMock(ResponseInterface::class);
29+
$this->networkException = $this->createMock(NetworkExceptionInterface::class);
30+
$this->sendAttempt = 0;
31+
}
32+
33+
public function testInvalidMaxAttempts(): void
34+
{
35+
$this->expectException(InvalidArgumentException::class);
36+
$this->expectExceptionMessage('maxAttempts must be >= 1');
37+
new RetryableClient($this->baseClient, maxAttempts: 0, baseDelay: 0);
38+
}
39+
40+
public function testInvalidBaseDelay(): void
41+
{
42+
$this->expectException(InvalidArgumentException::class);
43+
$this->expectExceptionMessage('baseDelay must be >= 0');
44+
new RetryableClient($this->baseClient, maxAttempts: 1, baseDelay: -1);
45+
}
46+
47+
public function testSendRequestSucceedsOnFirstAttempt(): void
48+
{
49+
$client = new RetryableClient($this->baseClient, maxAttempts: 1, baseDelay: 0);
50+
$this->baseClient->expects(self::exactly(1))->method('sendRequest')->with($this->testRequest)->willReturn($this->testResponse);
51+
self::assertSame($this->testResponse, $client->sendRequest($this->testRequest));
52+
}
53+
54+
public function testSendRequestSucceedsOnSecondAttempt(): void
55+
{
56+
$client = new RetryableClient($this->baseClient, maxAttempts: 2, baseDelay: 0);
57+
$this->baseClient->expects(self::exactly(2))->method('sendRequest')->with($this->testRequest)->willReturnCallback(fn() => ++$this->sendAttempt < 2 ? throw $this->networkException : $this->testResponse);
58+
self::assertSame($this->testResponse, $client->sendRequest($this->testRequest));
59+
}
60+
61+
public function testSendRequestSucceedsOnThirdAttempt(): void
62+
{
63+
$client = new RetryableClient($this->baseClient, maxAttempts: 3, baseDelay: 0);
64+
$this->baseClient->expects(self::exactly(3))->method('sendRequest')->with($this->testRequest)->willReturnCallback(fn() => ++$this->sendAttempt < 3 ? throw $this->networkException : $this->testResponse);
65+
self::assertSame($this->testResponse, $client->sendRequest($this->testRequest));
66+
}
67+
68+
public function testSendRequestFailsAfterFirstAttempt(): void
69+
{
70+
$client = new RetryableClient($this->baseClient, maxAttempts: 1, baseDelay: 0);
71+
$this->baseClient->expects(self::exactly(1))->method('sendRequest')->with($this->testRequest)->willThrowException($this->networkException);
72+
$this->expectException($this->networkException::class);
73+
$client->sendRequest($this->testRequest);
74+
}
75+
76+
public function testSendRequestFailsAfterTwoAttempts(): void
77+
{
78+
$client = new RetryableClient($this->baseClient, maxAttempts: 2, baseDelay: 0);
79+
$this->baseClient->expects(self::exactly(2))->method('sendRequest')->with($this->testRequest)->willThrowException($this->networkException);
80+
$this->expectException($this->networkException::class);
81+
$client->sendRequest($this->testRequest);
82+
}
83+
84+
public function testSendRequestFailsAfterThreeAttempts(): void
85+
{
86+
$client = new RetryableClient($this->baseClient, maxAttempts: 3, baseDelay: 0);
87+
$this->baseClient->expects(self::exactly(3))->method('sendRequest')->with($this->testRequest)->willThrowException($this->networkException);
88+
$this->expectException($this->networkException::class);
89+
$client->sendRequest($this->testRequest);
90+
}
91+
}

0 commit comments

Comments
 (0)