Skip to content

Commit 2f95425

Browse files
authored
Merge pull request #53 from bigcommerce/retry
Add retry support with the Retry interceptor
2 parents fddaafa + f990485 commit 2f95425

File tree

4 files changed

+217
-0
lines changed

4 files changed

+217
-0
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ Changelog for grphp.
22

33
### Pending Release
44

5+
### 3.5.1
6+
7+
* Add retry support with the Retry interceptor.
8+
9+
### 3.5.0
10+
11+
* Add methods for mapping status code numbers to descriptive strings
12+
513
### 3.4.0
614

715
* Set default request timeout to the proxy client config value.

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,25 @@ $client->addInterceptor($i);
161161

162162
Interceptors run in the order that they are added, wrapping each as they go.
163163

164+
## Retry Interceptor
165+
166+
Retries can be enabled for given gRPC error status codes with the `Retry` interceptor.
167+
```php
168+
use Grphp\Client\Interceptors\Retry;
169+
170+
$client->addInterceptor(new Retry(['max_retries' => 3]));
171+
```
172+
173+
The retry behaviour can be customized by passing in an array of options to the constructor. The following options are available:
174+
175+
| Option | Default | Description |
176+
|----------------------|---------------------------------------------------------------------------------------------|----------------------------------------------------------------|
177+
| `max_retries` | `3` | The maximum number of retries to attempt. |
178+
| `retry_on_statuses` | `[Grphp\Client\Error::CODE_UNAVAILABLE]` | An array of gRPC error status codes that should be retried on. |
179+
| `delay_milliseconds` | `200` | The initial delay in milliseconds before a retry. |
180+
| `backoff_func` | `function (int $attempt, int $delayMilliseconds) { /* exponential backoff with jitter */ }` | A callback defining the backoff behaviour. |
181+
182+
164183
## Error Handling
165184

166185
gRPC prefers handling errors through status (BadStatus) codes; however, these do not return much information as to
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
/**
3+
* Copyright (c) 2017-present, BigCommerce Pty. Ltd. All rights reserved
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6+
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7+
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
8+
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
9+
*
10+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11+
* Software.
12+
*
13+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14+
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16+
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
17+
*/
18+
declare(strict_types=1);
19+
20+
namespace Grphp\Client\Interceptors;
21+
22+
use Closure;
23+
use Grphp\Client\Error;
24+
use Grphp\Client\Error\Status;
25+
use Grphp\Client\Response;
26+
27+
class Retry extends Base
28+
{
29+
private const DEFAULT_MAX_RETRIES = 3;
30+
private const DEFAULT_DELAY_MS = 200;
31+
32+
private int $maxRetries;
33+
private int $delayMilliseconds;
34+
/** @var string[] */
35+
private array $retryOnStatuses;
36+
private Closure $backoffFunc;
37+
38+
/**
39+
* @param array{
40+
* max_retries: int,
41+
* delay_milliseconds: int,
42+
* retry_on_statuses: string[],
43+
* backoff_func: Closure,
44+
* } $options
45+
*/
46+
public function __construct(array $options = [])
47+
{
48+
parent::__construct($options);
49+
50+
$this->maxRetries = $options['max_retries'] ?? self::DEFAULT_MAX_RETRIES;
51+
$this->delayMilliseconds = $options['delay_milliseconds'] ?? self::DEFAULT_DELAY_MS;
52+
$this->retryOnStatuses = $options['retry_on_statuses'] ?? [Status::CODE_UNAVAILABLE];
53+
$this->backoffFunc = $options['backoff_func'] ?? function (int $attempt, int $delayMilliseconds) {
54+
$delay = pow(2, $attempt) * $delayMilliseconds;
55+
usleep(rand((int)($delay / 2), $delay) * 1000);
56+
};
57+
}
58+
59+
/**
60+
* @param callable $callback
61+
* @return Response
62+
* @throws Error
63+
*/
64+
public function call(callable $callback): Response
65+
{
66+
for ($attempt = 0; $attempt <= $this->maxRetries; $attempt ++) {
67+
try {
68+
return $callback();
69+
} catch (Error $e) {
70+
if ($attempt >= $this->maxRetries || !in_array($e->getStatusCode(), $this->retryOnStatuses)) {
71+
throw $e;
72+
}
73+
74+
call_user_func($this->backoffFunc, $attempt, $this->delayMilliseconds);
75+
}
76+
}
77+
}
78+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
/**
3+
* Copyright (c) 2017-present, BigCommerce Pty. Ltd. All rights reserved
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6+
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7+
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
8+
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
9+
*
10+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11+
* Software.
12+
*
13+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14+
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16+
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
17+
*/
18+
declare(strict_types=1);
19+
20+
namespace Unit\Grphp\Client\Interceptors;
21+
22+
use Grphp\Client\Config;
23+
use Grphp\Client\Error;
24+
use Grphp\Client\Interceptors\Retry;
25+
use Grphp\Client\Response;
26+
use PHPUnit\Framework\TestCase;
27+
use Prophecy\PhpUnit\ProphecyTrait;
28+
29+
class RetryTest extends TestCase
30+
{
31+
use ProphecyTrait;
32+
33+
private Retry $subject;
34+
35+
protected function setUp(): void
36+
{
37+
parent::setUp();
38+
39+
$this->subject = new Retry([
40+
'max_retries' => 3,
41+
'delay_milliseconds' => 1,
42+
]);
43+
}
44+
45+
public function testCallSuccessAfterRetries(): void
46+
{
47+
$callback = function () {
48+
static $attempts = 0;
49+
$attempts++;
50+
if ($attempts < 2) {
51+
throw new Error(
52+
new Config(),
53+
new Error\Status(14, 'test')
54+
);
55+
}
56+
57+
return $this->prophesize(Response::class)->reveal();
58+
};
59+
60+
$this->assertInstanceOf(Response::class, $this->subject->call($callback));
61+
}
62+
63+
public function testCallFailureAfterRetries(): void
64+
{
65+
$callback = function () {
66+
throw new Error(new Config(), new Error\Status(14, 'test'));
67+
};
68+
69+
$this->expectException(Error::class);
70+
$this->subject->call($callback);
71+
}
72+
73+
public function testCallFailureOnNonRetriableError(): void
74+
{
75+
$callback = function () {
76+
throw new Error(new Config(), new Error\Status(0, 'non-retriable error'));
77+
};
78+
79+
$this->expectException(Error::class);
80+
$this->subject->call($callback);
81+
}
82+
83+
public function testCallWithCustomBackoffFunc(): void
84+
{
85+
$callback = function () {
86+
static $attempts = 0;
87+
if ($attempts < 2) {
88+
$attempts++;
89+
throw new Error(
90+
new Config(),
91+
new Error\Status(14, 'test')
92+
);
93+
}
94+
95+
return $this->prophesize(Response::class)->reveal();
96+
};
97+
98+
$backoffCalled = 0;
99+
100+
$this->subject = new Retry([
101+
'max_retries' => 3,
102+
'delay_milliseconds' => 1,
103+
'backoff_func' => function (int $attempt, int $delayMilliseconds) use (&$backoffCalled) {
104+
$this->assertSame(1, $delayMilliseconds);
105+
$backoffCalled++;
106+
},
107+
]);
108+
109+
$this->assertInstanceOf(Response::class, $this->subject->call($callback));
110+
$this->assertSame(2, $backoffCalled);
111+
}
112+
}

0 commit comments

Comments
 (0)