@@ -82,6 +82,27 @@ class CURLRequest extends OutgoingRequest
8282 ],
8383 ];
8484
85+ /**
86+ * Default values for when 'retry' is enabled.
87+ *
88+ * @var array<string, bool|int|list<int>>
89+ */
90+ protected array $ retryDefaults = [
91+ 'max_retries ' => 3 ,
92+ 'delay ' => 1000 ,
93+ 'max_delay ' => 30_000 ,
94+ 'status_codes ' => [429 , 503 , 504 ],
95+ 'curl_errors ' => false ,
96+ 'respect_retry_after ' => true ,
97+ ];
98+
99+ /**
100+ * cURL error numbers that may succeed on another attempt.
101+ *
102+ * @var list<int>
103+ */
104+ protected array $ transientCurlErrors = [];
105+
85106 /**
86107 * The number of milliseconds to delay before
87108 * sending the request.
@@ -90,6 +111,11 @@ class CURLRequest extends OutgoingRequest
90111 */
91112 protected $ delay = 0.0 ;
92113
114+ /**
115+ * The last cURL error number.
116+ */
117+ protected int $ lastCurlError = 0 ;
118+
93119 /**
94120 * The default options from the constructor. Applied to all requests.
95121 */
@@ -127,6 +153,14 @@ public function __construct(App $config, URI $uri, ?ResponseInterface $response
127153 throw HTTPException::forMissingCurl (); // @codeCoverageIgnore
128154 }
129155
156+ $ this ->transientCurlErrors = [
157+ CURLE_COULDNT_RESOLVE_HOST ,
158+ CURLE_COULDNT_CONNECT ,
159+ CURLE_OPERATION_TIMEDOUT ,
160+ CURLE_SEND_ERROR ,
161+ CURLE_RECV_ERROR ,
162+ ];
163+
130164 parent ::__construct (Method::GET , $ uri );
131165
132166 $ this ->responseOrig = $ response ?? new Response ();
@@ -374,6 +408,8 @@ public function send(string $method, string $url)
374408 {
375409 // Reset our curl options so we're on a fresh slate.
376410 $ curlOptions = [];
411+ $ config = $ this ->config ;
412+ $ retry = $ this ->normalizeRetryOption ($ config ['retry ' ] ?? false );
377413
378414 if (! empty ($ this ->config ['query ' ]) && is_array ($ this ->config ['query ' ])) {
379415 // This is likely too naive a solution.
@@ -394,14 +430,75 @@ public function send(string $method, string $url)
394430 // Disable @file uploads in post data.
395431 $ curlOptions [CURLOPT_SAFE_UPLOAD ] = true ;
396432
397- $ curlOptions = $ this ->setCURLOptions ($ curlOptions , $ this -> config );
433+ $ curlOptions = $ this ->setCURLOptions ($ curlOptions , $ config );
398434 $ curlOptions = $ this ->applyMethod ($ method , $ curlOptions );
399435 $ curlOptions = $ this ->applyRequestHeaders ($ curlOptions );
400436
437+ if ($ retry !== null ) {
438+ $ curlOptions [CURLOPT_FAILONERROR ] = false ;
439+ }
440+
401441 // Do we need to delay this request?
402442 if ($ this ->delay > 0 ) {
403- usleep ((int ) $ this ->delay * 1_000_000 );
443+ $ this ->sleep ($ this ->delay );
444+ }
445+
446+ if ($ retry === null ) {
447+ return $ this ->sendAttempt ($ curlOptions );
448+ }
449+
450+ $ httpErrors = array_key_exists ('http_errors ' , $ config ) ? (bool ) $ config ['http_errors ' ] : true ;
451+
452+ return $ this ->sendWithRetries ($ curlOptions , $ retry , $ httpErrors );
453+ }
454+
455+ /**
456+ * Sends the request until it succeeds or retry attempts are exhausted.
457+ *
458+ * @param array<int, mixed> $curlOptions
459+ * @param array<string, bool|int|list<int>> $retry
460+ */
461+ protected function sendWithRetries (array $ curlOptions , array $ retry , bool $ httpErrors ): ResponseInterface
462+ {
463+ $ attempt = 0 ;
464+
465+ while (true ) {
466+ $ this ->response = clone $ this ->responseOrig ;
467+
468+ try {
469+ $ response = $ this ->sendAttempt ($ curlOptions );
470+ } catch (HTTPException $ e ) {
471+ if (! $ this ->shouldRetryCurlError ($ retry , $ attempt )) {
472+ throw $ e ;
473+ }
474+
475+ $ this ->sleep ($ this ->getRetryDelay ($ retry , $ attempt ) / 1000 );
476+ $ attempt ++;
477+
478+ continue ;
479+ }
480+
481+ if (! $ this ->shouldRetryResponse ($ response , $ retry , $ attempt )) {
482+ if ($ httpErrors && $ response ->getStatusCode () >= 400 ) {
483+ throw HTTPException::forCurlError ((string ) CURLE_HTTP_RETURNED_ERROR , 'The requested URL returned error: ' . $ response ->getStatusCode ());
484+ }
485+
486+ return $ response ;
487+ }
488+
489+ $ this ->sleep ($ this ->getRetryDelay ($ retry , $ attempt , $ response ) / 1000 );
490+ $ attempt ++;
404491 }
492+ }
493+
494+ /**
495+ * Sends a single cURL request attempt and populates the response.
496+ *
497+ * @param array<int, mixed> $curlOptions
498+ */
499+ protected function sendAttempt (array $ curlOptions ): ResponseInterface
500+ {
501+ $ this ->lastCurlError = 0 ;
405502
406503 $ output = $ this ->sendRequest ($ curlOptions );
407504
@@ -430,6 +527,158 @@ public function send(string $method, string $url)
430527 return $ this ->response ;
431528 }
432529
530+ /**
531+ * Normalizes the retry option into retry settings.
532+ *
533+ * @return array<string, bool|int|list<int>>|null
534+ */
535+ protected function normalizeRetryOption (mixed $ retry ): ?array
536+ {
537+ if (in_array ($ retry , [false , null , 0 ], true )) {
538+ return null ;
539+ }
540+
541+ $ config = $ this ->retryDefaults ;
542+
543+ if (is_int ($ retry )) {
544+ $ config ['max_retries ' ] = $ retry ;
545+ } elseif (is_array ($ retry )) {
546+ $ config = array_merge ($ config , $ retry );
547+ } else {
548+ return null ;
549+ }
550+
551+ $ config ['max_retries ' ] = max (0 , (int ) $ config ['max_retries ' ]);
552+
553+ if ($ config ['max_retries ' ] === 0 ) {
554+ return null ;
555+ }
556+
557+ $ config ['delay ' ] = $ this ->normalizeRetryDelay ($ config ['delay ' ]);
558+ $ config ['max_delay ' ] = max (0 , (int ) $ config ['max_delay ' ]);
559+ $ config ['status_codes ' ] = array_map (intval (...), (array ) $ config ['status_codes ' ]);
560+ $ config ['curl_errors ' ] = (bool ) $ config ['curl_errors ' ];
561+ $ config ['respect_retry_after ' ] = (bool ) $ config ['respect_retry_after ' ];
562+
563+ return $ config ;
564+ }
565+
566+ /**
567+ * Normalizes the retry delay setting.
568+ *
569+ * @return int|list<int>
570+ */
571+ protected function normalizeRetryDelay (mixed $ delay ): array |int
572+ {
573+ if (is_array ($ delay )) {
574+ return array_map (static fn ($ value ): int => max (0 , (int ) $ value ), $ delay );
575+ }
576+
577+ return max (0 , (int ) $ delay );
578+ }
579+
580+ /**
581+ * Determines whether a response should be retried.
582+ *
583+ * @param array<string, bool|int|list<int>> $retry
584+ */
585+ protected function shouldRetryResponse (ResponseInterface $ response , array $ retry , int $ attempt ): bool
586+ {
587+ if ($ attempt >= $ retry ['max_retries ' ]) {
588+ return false ;
589+ }
590+
591+ return in_array ($ response ->getStatusCode (), $ retry ['status_codes ' ], true );
592+ }
593+
594+ /**
595+ * Determines whether a cURL error should be retried.
596+ *
597+ * @param array<string, bool|int|list<int>> $retry
598+ */
599+ protected function shouldRetryCurlError (array $ retry , int $ attempt ): bool
600+ {
601+ if ($ attempt >= $ retry ['max_retries ' ] || $ retry ['curl_errors ' ] === false ) {
602+ return false ;
603+ }
604+
605+ return in_array ($ this ->lastCurlError , $ this ->transientCurlErrors , true );
606+ }
607+
608+ /**
609+ * Returns the delay before the next retry attempt.
610+ *
611+ * @param array<string, bool|int|list<int>> $retry
612+ */
613+ protected function getRetryDelay (array $ retry , int $ attempt , ?ResponseInterface $ response = null ): int
614+ {
615+ if ($ response instanceof ResponseInterface && $ retry ['respect_retry_after ' ] === true ) {
616+ $ retryAfter = $ this ->getRetryAfterDelay ($ response );
617+
618+ if ($ retryAfter !== null ) {
619+ return $ this ->limitRetryDelay ($ retryAfter * 1000 , $ retry );
620+ }
621+ }
622+
623+ $ delay = $ retry ['delay ' ];
624+
625+ if (is_array ($ delay )) {
626+ $ lastDelay = $ delay [array_key_last ($ delay )] ?? 0 ;
627+
628+ return $ this ->limitRetryDelay ((int ) ($ delay [$ attempt ] ?? $ lastDelay ), $ retry );
629+ }
630+
631+ return $ this ->limitRetryDelay ((int ) $ delay , $ retry );
632+ }
633+
634+ /**
635+ * Caps the retry delay when configured.
636+ *
637+ * @param array<string, bool|int|list<int>> $retry
638+ */
639+ protected function limitRetryDelay (int $ delay , array $ retry ): int
640+ {
641+ $ maxDelay = (int ) $ retry ['max_delay ' ];
642+
643+ if ($ maxDelay === 0 ) {
644+ return $ delay ;
645+ }
646+
647+ return min ($ delay , $ maxDelay );
648+ }
649+
650+ /**
651+ * Returns the delay from a Retry-After header in seconds.
652+ */
653+ protected function getRetryAfterDelay (ResponseInterface $ response ): ?int
654+ {
655+ $ retryAfter = $ response ->getHeaderLine ('Retry-After ' );
656+
657+ if ($ retryAfter === '' ) {
658+ return null ;
659+ }
660+
661+ if (ctype_digit ($ retryAfter )) {
662+ return (int ) $ retryAfter ;
663+ }
664+
665+ $ timestamp = strtotime ($ retryAfter );
666+
667+ if ($ timestamp === false ) {
668+ return null ;
669+ }
670+
671+ return max (0 , $ timestamp - time ());
672+ }
673+
674+ /**
675+ * Sleeps for the configured number of seconds.
676+ */
677+ protected function sleep (float $ seconds ): void
678+ {
679+ usleep ((int ) ($ seconds * 1_000_000 ));
680+ }
681+
433682 /**
434683 * Adds $this->headers to the cURL request.
435684 */
@@ -731,7 +980,9 @@ protected function sendRequest(array $curlOptions = []): string
731980 $ output = curl_exec ($ ch );
732981
733982 if ($ output === false ) {
734- throw HTTPException::forCurlError ((string ) curl_errno ($ ch ), curl_error ($ ch ));
983+ $ this ->lastCurlError = curl_errno ($ ch );
984+
985+ throw HTTPException::forCurlError ((string ) $ this ->lastCurlError , curl_error ($ ch ));
735986 }
736987
737988 return $ output ;
0 commit comments