Skip to content

Commit b865372

Browse files
authored
Merge pull request #185 from tomwalder/feature/retry-backoff
Support for exponential back-off.
2 parents 37349a6 + a4e528c commit b865372

File tree

7 files changed

+303
-21
lines changed

7 files changed

+303
-21
lines changed

README.md

+17-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ This library is intended to make it easier for you to get started with and to us
99

1010
## Quick Start ##
1111
```bash
12-
composer require "tomwalder/php-gds:^5.1"
12+
composer require "tomwalder/php-gds:^6.1"
1313
```
1414
```php
1515
// Build a new entity
@@ -28,6 +28,18 @@ foreach($obj_store->fetchAll() as $obj_book) {
2828
}
2929
```
3030

31+
## New in Version 6.1 ##
32+
33+
Support for automated exponential backoff for some types of errors. See documentation here:
34+
https://cloud.google.com/datastore/docs/concepts/errors
35+
36+
To enable:
37+
```php
38+
\GDS\Gateway::exponentialBackoff(true);
39+
```
40+
41+
Version `6.0.0` introduced better (but different) support for `NULL` values.
42+
3143
## New in Version 5.0 ##
3244

3345
**As of version 5 (May 2021), this library provides support for**
@@ -523,6 +535,10 @@ A full suite of unit tests is in the works. Assuming you've installed `php-gds`
523535
```bash
524536
vendor/bin/phpunit
525537
```
538+
Or, if you need to run containerised tests, you can use the `runphp` image (or any you choose)
539+
```bash
540+
docker run --rm -it -v`pwd`:/app -w /app fluentthinking/runphp:7.4.33-v0.9.0 php /app/vendor/bin/phpunit
541+
```
526542

527543
[Click here for more details](tests/).
528544

src/GDS/Gateway.php

+75
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
*/
3030
abstract class Gateway
3131
{
32+
// 8 = about 5 seconds total, with last gap ~2.5 seconds
33+
const RETRY_MAX_ATTEMPTS = 8;
3234

3335
/**
3436
* The dataset ID
@@ -72,6 +74,19 @@ abstract class Gateway
7274
*/
7375
protected $arr_kind_mappers = [];
7476

77+
protected static $bol_retry = false;
78+
79+
/**
80+
* Configure gateway retries (for 503, 500 responses)
81+
*
82+
* @param bool $bol_retry
83+
* @return void
84+
*/
85+
public static function exponentialBackoff(bool $bol_retry = true)
86+
{
87+
self::$bol_retry = $bol_retry;
88+
}
89+
7590
/**
7691
* Set the Schema to be used next (once?)
7792
*
@@ -335,6 +350,66 @@ protected function configureValueParamForQuery($obj_val, $mix_value)
335350
return $obj_val;
336351
}
337352

353+
/**
354+
* Delay execution, based on the attempt number
355+
*
356+
* @param int $int_attempt
357+
* @return void
358+
*/
359+
protected function backoff(int $int_attempt)
360+
{
361+
$int_backoff = (int) pow(2, $int_attempt);
362+
$int_jitter = rand(0, 10) * 1000;
363+
$int_delay = ($int_backoff * 10000) + $int_jitter;
364+
usleep($int_delay);
365+
}
366+
367+
/**
368+
* Execute the callback with exponential backoff
369+
*
370+
* @param callable $fnc_main
371+
* @param string|null $str_exception
372+
* @param callable|null $fnc_resolve_exception
373+
* @return mixed
374+
* @throws \Throwable
375+
*/
376+
protected function executeWithExponentialBackoff(
377+
callable $fnc_main,
378+
string $str_exception = null,
379+
callable $fnc_resolve_exception = null
380+
) {
381+
$int_attempt = 0;
382+
$bol_retry_once = false;
383+
do {
384+
try {
385+
$int_attempt++;
386+
if ($int_attempt > 1) {
387+
$this->backoff($int_attempt);
388+
}
389+
return $fnc_main();
390+
} catch (\Throwable $obj_thrown) {
391+
// Rethrow if we're not interested in this Exception type
392+
if (null !== $str_exception && !$obj_thrown instanceof $str_exception) {
393+
throw $obj_thrown;
394+
}
395+
// Rethrow if retry is disabled, non-retryable errors, or if we have hit a retry limit
396+
if (false === self::$bol_retry ||
397+
true === $bol_retry_once ||
398+
!in_array((int) $obj_thrown->getCode(), static::RETRY_ERROR_CODES)
399+
) {
400+
throw null === $fnc_resolve_exception ? $obj_thrown : $fnc_resolve_exception($obj_thrown);
401+
}
402+
// Just one retry for some errors
403+
if (in_array((int) $obj_thrown->getCode(), static::RETRY_ONCE_CODES)) {
404+
$bol_retry_once = true;
405+
}
406+
}
407+
} while ($int_attempt < self::RETRY_MAX_ATTEMPTS);
408+
409+
// We could not make this work after max retries
410+
throw null === $fnc_resolve_exception ? $obj_thrown : $fnc_resolve_exception($obj_thrown);
411+
}
412+
338413
/**
339414
* Configure a Value parameter, based on the supplied object-type value
340415
*

src/GDS/Gateway/GRPCv1.php

+46-16
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Google\Cloud\Datastore\V1\GqlQuery;
3636
use Google\Cloud\Datastore\V1\GqlQueryParameter;
3737
use Google\Cloud\Datastore\V1\Value;
38+
use Google\Rpc\Code;
3839

3940
/**
4041
* gRPC Datastore Gateway (v1)
@@ -46,6 +47,20 @@
4647
*/
4748
class GRPCv1 extends \GDS\Gateway
4849
{
50+
// https://cloud.google.com/datastore/docs/concepts/errors
51+
// https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
52+
const RETRY_ERROR_CODES = [
53+
Code::UNKNOWN,
54+
Code::ABORTED,
55+
Code::DEADLINE_EXCEEDED,
56+
Code::RESOURCE_EXHAUSTED,
57+
Code::UNAVAILABLE,
58+
Code::INTERNAL,
59+
];
60+
61+
const RETRY_ONCE_CODES = [
62+
Code::INTERNAL,
63+
];
4964

5065
/**
5166
* Cloud Datastore (gRPC & REST) Client
@@ -98,29 +113,44 @@ private function createPartitionId()
98113
/**
99114
* Execute a method against the Datastore client.
100115
*
116+
* Prepend projectId as first parameter automatically.
117+
*
101118
* @param string $str_method
102119
* @param mixed[] $args
103120
* @return mixed
104121
* @throws \Exception
105122
*/
106-
private function execute($str_method, $args)
123+
private function execute(string $str_method, array $args) {
124+
array_unshift($args, $this->str_dataset_id);
125+
return $this->executeWithExponentialBackoff(
126+
function () use ($str_method, $args) {
127+
$this->obj_last_response = call_user_func_array([self::$obj_datastore_client, $str_method], $args);
128+
return $this->obj_last_response;
129+
},
130+
ApiException::class,
131+
[$this, 'resolveExecuteException']
132+
);
133+
}
134+
135+
/**
136+
* Wrap the somewhat murky ApiException into something more useful
137+
*
138+
* https://cloud.google.com/datastore/docs/concepts/errors
139+
*
140+
* @param ApiException $obj_exception
141+
* @return \Exception
142+
*/
143+
protected function resolveExecuteException(ApiException $obj_exception): \Exception
107144
{
108-
try {
109-
// Call gRPC client,
110-
// prepend projectId as first parameter automatically.
111-
array_unshift($args, $this->str_dataset_id);
112-
$this->obj_last_response = call_user_func_array([self::$obj_datastore_client, $str_method], $args);
113-
} catch (ApiException $obj_exception) {
114-
$this->obj_last_response = null;
115-
if (FALSE !== strpos($obj_exception->getMessage(), 'too much contention') || FALSE !== strpos($obj_exception->getMessage(), 'Concurrency')) {
116-
// LIVE: "too much contention on these datastore entities. please try again." LOCAL : "Concurrency exception."
117-
throw new Contention('Datastore contention', 409, $obj_exception);
118-
} else {
119-
throw $obj_exception;
120-
}
145+
$this->obj_last_response = null;
146+
if (Code::ABORTED === $obj_exception->getCode() ||
147+
false !== strpos($obj_exception->getMessage(), 'too much contention') ||
148+
false !== strpos($obj_exception->getMessage(), 'Concurrency')) {
149+
// LIVE: "too much contention on these datastore entities. please try again."
150+
// LOCAL : "Concurrency exception."
151+
return new Contention('Datastore contention', 409, $obj_exception);
121152
}
122-
123-
return $this->obj_last_response;
153+
return $obj_exception;
124154
}
125155

126156
/**

src/GDS/Gateway/RESTv1.php

+16-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use GuzzleHttp\Client;
66
use GuzzleHttp\ClientInterface;
77
use GuzzleHttp\HandlerStack;
8+
use Psr\Http\Message\ResponseInterface;
89

910
/**
1011
* Gateway, implementing the Datastore API v1 over REST
@@ -23,6 +24,12 @@ class RESTv1 extends \GDS\Gateway
2324
const MODE_NON_TRANSACTIONAL = 'NON_TRANSACTIONAL';
2425
const MODE_UNSPECIFIED = 'UNSPECIFIED';
2526

27+
// https://cloud.google.com/datastore/docs/concepts/errors
28+
// https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
29+
const RETRY_ERROR_CODES = [409, 429, 500, 503, 504];
30+
31+
const RETRY_ONCE_CODES = [500];
32+
2633
/**
2734
* Client config keys.
2835
*/
@@ -165,8 +172,14 @@ private function executePostRequest($str_action, $obj_request_body = null)
165172
if(null !== $obj_request_body) {
166173
$arr_options['json'] = $obj_request_body;
167174
}
168-
$obj_response = $this->httpClient()->post($this->actionUrl($str_action), $arr_options);
169-
$this->obj_last_response = json_decode((string)$obj_response->getBody());
175+
$str_url = $this->actionUrl($str_action);
176+
$obj_response = $this->executeWithExponentialBackoff(
177+
function () use ($str_url, $arr_options) {
178+
return $this->httpClient()->post($str_url, $arr_options);
179+
},
180+
\GuzzleHttp\Exception\RequestException::class
181+
);
182+
$this->obj_last_response = \json_decode((string)$obj_response->getBody());
170183
}
171184

172185
/**
@@ -492,7 +505,7 @@ public function beginTransaction($bol_cross_group = FALSE)
492505
* @return string
493506
*/
494507
protected function getBaseUrl() {
495-
$str_base_url = $this->obj_http_client->getConfig(self::CONFIG_CLIENT_BASE_URL);
508+
$str_base_url = $this->httpClient()->getConfig(self::CONFIG_CLIENT_BASE_URL);
496509
if (!empty($str_base_url)) {
497510
return $str_base_url;
498511
}

0 commit comments

Comments
 (0)