@@ -28,6 +28,9 @@ class RequestBuilder {
28
28
/** @var int */
29
29
protected $ retry = 0 ;
30
30
31
+ /** @var int */
32
+ protected $ retry_429 = 5 ;
33
+
31
34
/** @var int */
32
35
protected $ timeout_in_seconds = 15 ;
33
36
@@ -145,14 +148,6 @@ public function request(): string {
145
148
throw new DoingAutocompleteException ();
146
149
}
147
150
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
-
156
151
$ curl = curl_init ();
157
152
158
153
$ curl_parameters = [
@@ -162,6 +157,7 @@ public function request(): string {
162
157
CURLOPT_POSTREDIR => CURL_REDIR_POST_ALL ,
163
158
CURLOPT_CONNECTTIMEOUT => $ this ->timeout_in_seconds ,
164
159
CURLOPT_TIMEOUT => $ this ->timeout_in_seconds ,
160
+ CURLOPT_HEADER => 1 ,
165
161
];
166
162
167
163
if ( App::make ( Output::class )->isVeryVerbose () ) {
@@ -231,34 +227,52 @@ public function request(): string {
231
227
App::make ( Output::class )->writeln ( sprintf ( '[QIT DEBUG] Running external request: %s ' , json_encode ( $ request_in_logs , JSON_PRETTY_PRINT ) ) );
232
228
}
233
229
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
+
236
238
$ response_status_code = curl_getinfo ( $ curl , CURLINFO_HTTP_CODE );
237
239
curl_close ( $ curl );
238
240
239
241
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 () );
242
244
}
243
245
244
246
if ( ! empty ( $ curl_error ) ) {
245
247
// Network error, such as a timeout, etc.
246
248
$ error_message = $ curl_error ;
247
249
} else {
248
250
// Application error, such as invalid parameters, etc.
249
- $ error_message = $ result ;
251
+ $ error_message = $ body ;
250
252
$ json_response = json_decode ( $ error_message , true );
251
253
252
254
if ( is_array ( $ json_response ) && array_key_exists ( 'message ' , $ json_response ) ) {
253
255
$ error_message = $ json_response ['message ' ];
254
256
}
255
257
}
256
258
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
+ }
262
276
}
263
277
264
278
throw new NetworkErrorException (
@@ -272,7 +286,67 @@ public function request(): string {
272
286
);
273
287
}
274
288
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 ;
276
350
}
277
351
278
352
/**
0 commit comments