Skip to content

Commit 72d145d

Browse files
authored
Merge pull request #137 from woocommerce/24-03/retry-after-429
Retry after 429
2 parents 882763d + 4dc9246 commit 72d145d

8 files changed

+171
-35
lines changed

_tests/QITSelfTests.php

-7
Original file line numberDiff line numberDiff line change
@@ -290,9 +290,6 @@ function run_test_runs( array $test_runs ) {
290290

291291
$qit_run_processes = [];
292292

293-
// If running a lot of tests, wait between any remote request to prevent 429.
294-
$should_wait = array_sum( array_map( 'count', $test_runs ) ) > 3;
295-
296293
// Dispatch all tests in parallel using the qit binary.
297294
foreach ( $test_runs as $test_type => &$test_type_test_runs ) {
298295
foreach ( $test_type_test_runs as &$t ) {
@@ -376,10 +373,6 @@ function run_test_runs( array $test_runs ) {
376373
'QIT_NON_JSON_OUTPUT' => $t['non_json_output_file'],
377374
];
378375

379-
if ( $should_wait ) {
380-
$env['QIT_WAIT_BEFORE_REQUEST'] = 'yes';
381-
}
382-
383376
$qit_process->setEnv( $env );
384377

385378
add_task_id_to_process( $qit_process, $t );

_tests/tests/__snapshots__/ActivationTest__test_activation_genericphp74wp62_woostable_php74_wprc_f236c472885ad1cea386db6a9f198c5e__1.php

+3-3
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
},
9898
{
9999
"file": "\\/var\\/www\\/html\\/wp-settings.php",
100-
"line": 679,
100+
"line": 695,
101101
"function": "do_action"
102102
},
103103
{
@@ -207,7 +207,7 @@
207207
},
208208
{
209209
"file": "\\/var\\/www\\/html\\/wp-settings.php",
210-
"line": 679,
210+
"line": 695,
211211
"function": "do_action"
212212
},
213213
{
@@ -279,7 +279,7 @@
279279
},
280280
{
281281
"file": "\\/var\\/www\\/html\\/wp-settings.php",
282-
"line": 679,
282+
"line": 695,
283283
"function": "do_action"
284284
},
285285
{

_tests/tests/__snapshots__/ActivationTest__test_activation_php81_woorc_php81_wprc_15d317e57322fe19f87aeeb57cad8452__1.php

+3-3
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
},
9191
{
9292
"file": "\\/var\\/www\\/html\\/wp-settings.php",
93-
"line": 679,
93+
"line": 695,
9494
"function": "do_action"
9595
},
9696
{
@@ -203,7 +203,7 @@
203203
},
204204
{
205205
"file": "\\/var\\/www\\/html\\/wp-settings.php",
206-
"line": 679,
206+
"line": 695,
207207
"function": "do_action"
208208
},
209209
{
@@ -316,7 +316,7 @@
316316
},
317317
{
318318
"file": "\\/var\\/www\\/html\\/wp-settings.php",
319-
"line": 679,
319+
"line": 695,
320320
"function": "do_action"
321321
},
322322
{

_tests/tests/__snapshots__/ActivationTest__test_activation_php81_woostable_php81_wprc_83dc095f2903767ea60c9a7e396dd4d0__1.php

+3-3
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
},
9191
{
9292
"file": "\\/var\\/www\\/html\\/wp-settings.php",
93-
"line": 679,
93+
"line": 695,
9494
"function": "do_action"
9595
},
9696
{
@@ -203,7 +203,7 @@
203203
},
204204
{
205205
"file": "\\/var\\/www\\/html\\/wp-settings.php",
206-
"line": 679,
206+
"line": 695,
207207
"function": "do_action"
208208
},
209209
{
@@ -316,7 +316,7 @@
316316
},
317317
{
318318
"file": "\\/var\\/www\\/html\\/wp-settings.php",
319-
"line": 679,
319+
"line": 695,
320320
"function": "do_action"
321321
},
322322
{

qit

1.89 KB
Binary file not shown.

src/src/RequestBuilder.php

+93-19
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class RequestBuilder {
2828
/** @var int */
2929
protected $retry = 0;
3030

31+
/** @var int */
32+
protected $retry_429 = 5;
33+
3134
/** @var int */
3235
protected $timeout_in_seconds = 15;
3336

@@ -145,14 +148,6 @@ public function request(): string {
145148
throw new DoingAutocompleteException();
146149
}
147150

148-
// Allow to wait from the outside to avoid 429 on parallel tests.
149-
if ( getenv( 'QIT_WAIT_BEFORE_REQUEST' ) === 'yes' ) {
150-
// Wait between 1 and 60 seconds.
151-
$to_wait = rand( intval( 1 * 1e6 ), intval( 60 * 1e6 ) );
152-
usleep( $to_wait );
153-
App::make( Output::class )->writeln( sprintf( 'Waiting %d seconds before request...', number_format( $to_wait / 1e6, 2 ) ) );
154-
}
155-
156151
$curl = curl_init();
157152

158153
$curl_parameters = [
@@ -162,6 +157,7 @@ public function request(): string {
162157
CURLOPT_POSTREDIR => CURL_REDIR_POST_ALL,
163158
CURLOPT_CONNECTTIMEOUT => $this->timeout_in_seconds,
164159
CURLOPT_TIMEOUT => $this->timeout_in_seconds,
160+
CURLOPT_HEADER => 1,
165161
];
166162

167163
if ( App::make( Output::class )->isVeryVerbose() ) {
@@ -231,34 +227,52 @@ public function request(): string {
231227
App::make( Output::class )->writeln( sprintf( '[QIT DEBUG] Running external request: %s', json_encode( $request_in_logs, JSON_PRETTY_PRINT ) ) );
232228
}
233229

234-
$result = curl_exec( $curl );
235-
$curl_error = curl_error( $curl );
230+
$result = curl_exec( $curl );
231+
$curl_error = curl_error( $curl );
232+
233+
// Extract header size and separate headers from body.
234+
$header_size = curl_getinfo( $curl, CURLINFO_HEADER_SIZE );
235+
$headers = substr( $result, 0, $header_size );
236+
$body = substr( $result, $header_size );
237+
236238
$response_status_code = curl_getinfo( $curl, CURLINFO_HTTP_CODE );
237239
curl_close( $curl );
238240

239241
if ( ! in_array( $response_status_code, $this->expected_status_codes, true ) ) {
240-
if ( $proxied && $result === false ) {
241-
$result = sprintf( 'Is the Automattic Proxy running and accessible through %s?', Config::get_proxy_url() );
242+
if ( $proxied && $body === false ) {
243+
$body = sprintf( 'Is the Automattic Proxy running and accessible through %s?', Config::get_proxy_url() );
242244
}
243245

244246
if ( ! empty( $curl_error ) ) {
245247
// Network error, such as a timeout, etc.
246248
$error_message = $curl_error;
247249
} else {
248250
// Application error, such as invalid parameters, etc.
249-
$error_message = $result;
251+
$error_message = $body;
250252
$json_response = json_decode( $error_message, true );
251253

252254
if ( is_array( $json_response ) && array_key_exists( 'message', $json_response ) ) {
253255
$error_message = $json_response['message'];
254256
}
255257
}
256258

257-
if ( $this->retry > 0 ) {
258-
$this->retry --;
259-
App::make( Output::class )->writeln( '<comment>Request failed... Retrying.</comment>' );
260-
usleep( rand( intval( 5 * 1e5 ), intval( 5 * 1e6 ) ) ); // Sleep between 0.5s and 5s.
261-
goto retry_request; // phpcs:ignore Generic.PHP.DiscourageGoto.Found
259+
if ( $response_status_code === 429 ) {
260+
if ( $this->retry_429 > 0 ) {
261+
$this->retry_429 --;
262+
App::make( Output::class )->writeln( '<comment>Request failed... Retrying (429 Too many Requests)</comment>' );
263+
264+
sleep( $this->wait_after_429( $headers ) );
265+
goto retry_request; // phpcs:ignore Generic.PHP.DiscourageGoto.Found
266+
}
267+
} else {
268+
if ( $this->retry > 0 ) {
269+
$this->retry --;
270+
App::make( Output::class )->writeln( sprintf( '<comment>Request failed... Retrying (HTTP Status Code %s)</comment>', $response_status_code ) );
271+
272+
// Between 1 and 5s.
273+
sleep( rand( 1, 5 ) );
274+
goto retry_request; // phpcs:ignore Generic.PHP.DiscourageGoto.Found
275+
}
262276
}
263277

264278
throw new NetworkErrorException(
@@ -272,7 +286,67 @@ public function request(): string {
272286
);
273287
}
274288

275-
return $result;
289+
return $body;
290+
}
291+
292+
protected function wait_after_429( string $headers, int $max_wait = 60 ): int {
293+
$retry_after = null;
294+
295+
// HTTP dates are always expressed in GMT, never in local time. (RFC 9110 5.6.7).
296+
$gmt_timezone = new \DateTimeZone( 'GMT' );
297+
298+
// HTTP headers are case-insensitive according to RFC 7230.
299+
$headers = strtolower( $headers );
300+
301+
foreach ( explode( "\r\n", $headers ) as $header ) {
302+
/**
303+
* Retry-After header is specified by RFC 9110 10.2.3
304+
*
305+
* It can be formatted as http-date, or int (seconds).
306+
*
307+
* Retry-After: Fri, 31 Dec 1999 23:59:59 GMT
308+
* Retry-After: 120
309+
*
310+
* @link https://datatracker.ietf.org/doc/html/rfc9110#section-10.2.3
311+
*/
312+
if ( strpos( $header, 'retry-after:' ) !== false ) {
313+
$retry_after_header = trim( substr( $header, strpos( $header, ':' ) + 1 ) );
314+
315+
// seconds.
316+
if ( is_numeric( $retry_after_header ) ) {
317+
$retry_after = intval( $retry_after_header );
318+
} else {
319+
// Parse as HTTP-date in GMT timezone.
320+
try {
321+
$retry_after = ( new \DateTime( $retry_after_header, $gmt_timezone ) )->getTimestamp() - ( new \DateTime( 'now', $gmt_timezone ) )->getTimestamp();
322+
} catch ( \Exception $e ) {
323+
$retry_after = null;
324+
}
325+
// http-date.
326+
$retry_after_time = strtotime( $retry_after_header );
327+
if ( $retry_after_time !== false ) {
328+
$retry_after = $retry_after_time - time();
329+
}
330+
}
331+
332+
if ( ! defined( 'UNIT_TESTS' ) ) {
333+
App::make( Output::class )->writeln( sprintf( 'Got 429. Retrying after %d seconds...', $retry_after ) );
334+
}
335+
}
336+
}
337+
338+
// If no retry-after is specified, do a back-off.
339+
if ( is_null( $retry_after ) ) {
340+
$retry_after = 5 * pow( 2, abs( $this->retry_429 - 5 ) );
341+
}
342+
343+
// Ensure we wait at least 1 second.
344+
$retry_after = max( 1, $retry_after );
345+
346+
// And no longer than 60 seconds.
347+
$retry_after = min( $max_wait, $retry_after );
348+
349+
return $retry_after;
276350
}
277351

278352
/**

src/src/bootstrap.php

+1
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ public function filter( $in, $out, &$consumed, $closing ): int {
9999
if ( ! stream_filter_register( 'qit_json', QIT_JSON_Filter::class ) ) {
100100
exit( 151 );
101101
}
102+
/* @phan-suppress-next-line PhanUndeclaredMethod */
102103
if ( ! stream_filter_append( App::make( Output::class )->getStream(), 'qit_json' ) ) {
103104
exit( 152 );
104105
}

src/tests/RequestBuilderTest.php

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace QIT_CLI_Tests;
4+
5+
use QIT_CLI\RequestBuilder;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class RequestBuilderTest extends TestCase {
9+
/** @var RequestBuilder $this ->sut */
10+
protected $sut;
11+
12+
protected function setUp(): void {
13+
parent::setUp();
14+
$this->sut = new class extends RequestBuilder {
15+
public $retry_429 = 5;
16+
17+
public function wait_after_429( string $headers, int $max_wait = 60 ): int {
18+
return parent::wait_after_429( $headers, $max_wait );
19+
}
20+
};
21+
}
22+
23+
public function test_retry_after_seconds() {
24+
$headers = "Retry-After: 59\r\nOther-Header: value";
25+
26+
$this->assertEquals( 59, $this->sut->wait_after_429( $headers ) );
27+
}
28+
29+
public function test_retry_after_http_date() {
30+
$dateTime = new \DateTime( '+120 seconds' );
31+
$httpDate = $dateTime->format( \DateTimeInterface::RFC7231 );
32+
$headers = "Retry-After: $httpDate\r\nOther-Header: value";
33+
34+
// Since time will pass between the creation of the date and this calculation,
35+
// allow a small margin in the assertion
36+
$expected_delay = $dateTime->getTimestamp() - time();
37+
$this->assertEqualsWithDelta( $expected_delay, $this->sut->wait_after_429( $headers, 130 ), 5 );
38+
}
39+
40+
public function test_no_retry_after_header() {
41+
$headers = "Other-Header: value";
42+
43+
$this->assertEquals( 5, $this->sut->wait_after_429( $headers ) );
44+
}
45+
46+
public function test_invalid_retry_after_header() {
47+
$headers = "Retry-After: invalid\r\nOther-Header: value";
48+
49+
$this->assertEquals( 5, $this->sut->wait_after_429( $headers ) );
50+
}
51+
52+
public function test_exponential_backoff() {
53+
$headers = "Retry-After: invalid\r\nOther-Header: value";
54+
55+
$this->assertEquals( 5, $this->sut->wait_after_429( $headers ) );
56+
// Mimick integration-level test.
57+
$this->sut->retry_429 --;
58+
$this->assertEquals( 10, $this->sut->wait_after_429( $headers ) );
59+
$this->sut->retry_429 --;
60+
$this->assertEquals( 20, $this->sut->wait_after_429( $headers ) );
61+
$this->sut->retry_429 --;
62+
$this->assertEquals( 40, $this->sut->wait_after_429( $headers ) );
63+
$this->sut->retry_429 --;
64+
$this->assertEquals( 80, $this->sut->wait_after_429( $headers, 300 ) );
65+
$this->sut->retry_429 --;
66+
$this->assertEquals( 160, $this->sut->wait_after_429( $headers, 300 ) );
67+
}
68+
}

0 commit comments

Comments
 (0)