@@ -82,6 +82,33 @@ 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 $ retryDefaults = [
91+ 'max_retries ' => 3 ,
92+ 'delay ' => 1000 ,
93+ 'max_delay ' => 30_000 ,
94+ 'status_codes ' => [429 , 503 ],
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 $ transientCurlErrors = [
105+ CURLE_COULDNT_RESOLVE_HOST ,
106+ CURLE_COULDNT_CONNECT ,
107+ CURLE_OPERATION_TIMEDOUT ,
108+ CURLE_SEND_ERROR ,
109+ CURLE_RECV_ERROR ,
110+ ];
111+
85112 /**
86113 * The number of milliseconds to delay before
87114 * sending the request.
@@ -90,6 +117,11 @@ class CURLRequest extends OutgoingRequest
90117 */
91118 protected $ delay = 0.0 ;
92119
120+ /**
121+ * The last cURL error number.
122+ */
123+ protected int $ lastCurlError = 0 ;
124+
93125 /**
94126 * The default options from the constructor. Applied to all requests.
95127 */
@@ -374,6 +406,8 @@ public function send(string $method, string $url)
374406 {
375407 // Reset our curl options so we're on a fresh slate.
376408 $ curlOptions = [];
409+ $ config = $ this ->config ;
410+ $ retry = $ this ->normalizeRetryOption ($ config ['retry ' ] ?? false );
377411
378412 if (! empty ($ this ->config ['query ' ]) && is_array ($ this ->config ['query ' ])) {
379413 // This is likely too naive a solution.
@@ -394,14 +428,75 @@ public function send(string $method, string $url)
394428 // Disable @file uploads in post data.
395429 $ curlOptions [CURLOPT_SAFE_UPLOAD ] = true ;
396430
397- $ curlOptions = $ this ->setCURLOptions ($ curlOptions , $ this -> config );
431+ $ curlOptions = $ this ->setCURLOptions ($ curlOptions , $ config );
398432 $ curlOptions = $ this ->applyMethod ($ method , $ curlOptions );
399433 $ curlOptions = $ this ->applyRequestHeaders ($ curlOptions );
400434
435+ if ($ retry !== null ) {
436+ $ curlOptions [CURLOPT_FAILONERROR ] = false ;
437+ }
438+
401439 // Do we need to delay this request?
402440 if ($ this ->delay > 0 ) {
403- usleep ((int ) $ this ->delay * 1_000_000 );
441+ $ this ->sleep ($ this ->delay );
442+ }
443+
444+ if ($ retry === null ) {
445+ return $ this ->sendAttempt ($ curlOptions );
446+ }
447+
448+ $ httpErrors = array_key_exists ('http_errors ' , $ config ) ? (bool ) $ config ['http_errors ' ] : true ;
449+
450+ return $ this ->sendWithRetries ($ curlOptions , $ retry , $ httpErrors );
451+ }
452+
453+ /**
454+ * Sends the request until it succeeds or retry attempts are exhausted.
455+ *
456+ * @param array<int, mixed> $curlOptions
457+ * @param array<string, bool|int|list<int>> $retry
458+ */
459+ protected function sendWithRetries (array $ curlOptions , array $ retry , bool $ httpErrors ): ResponseInterface
460+ {
461+ $ attempt = 0 ;
462+
463+ while (true ) {
464+ $ this ->response = clone $ this ->responseOrig ;
465+
466+ try {
467+ $ response = $ this ->sendAttempt ($ curlOptions );
468+ } catch (HTTPException $ e ) {
469+ if (! $ this ->shouldRetryCurlError ($ retry , $ attempt )) {
470+ throw $ e ;
471+ }
472+
473+ $ this ->sleep ($ this ->getRetryDelay ($ retry , $ attempt ) / 1000 );
474+ $ attempt ++;
475+
476+ continue ;
477+ }
478+
479+ if (! $ this ->shouldRetryResponse ($ response , $ retry , $ attempt )) {
480+ if ($ httpErrors && $ response ->getStatusCode () >= 400 ) {
481+ throw HTTPException::forCurlError ('22 ' , 'The requested URL returned error: ' . $ response ->getStatusCode ());
482+ }
483+
484+ return $ response ;
485+ }
486+
487+ $ this ->sleep ($ this ->getRetryDelay ($ retry , $ attempt , $ response ) / 1000 );
488+ $ attempt ++;
404489 }
490+ }
491+
492+ /**
493+ * Sends a single cURL request attempt and populates the response.
494+ *
495+ * @param array<int, mixed> $curlOptions
496+ */
497+ protected function sendAttempt (array $ curlOptions ): ResponseInterface
498+ {
499+ $ this ->lastCurlError = 0 ;
405500
406501 $ output = $ this ->sendRequest ($ curlOptions );
407502
@@ -430,6 +525,158 @@ public function send(string $method, string $url)
430525 return $ this ->response ;
431526 }
432527
528+ /**
529+ * Normalizes the retry option into retry settings.
530+ *
531+ * @return array<string, bool|int|list<int>>|null
532+ */
533+ protected function normalizeRetryOption (mixed $ retry ): ?array
534+ {
535+ if (in_array ($ retry , [false , null , 0 ], true )) {
536+ return null ;
537+ }
538+
539+ $ config = $ this ->retryDefaults ;
540+
541+ if (is_int ($ retry )) {
542+ $ config ['max_retries ' ] = $ retry ;
543+ } elseif (is_array ($ retry )) {
544+ $ config = array_merge ($ config , $ retry );
545+ } else {
546+ return null ;
547+ }
548+
549+ $ config ['max_retries ' ] = max (0 , (int ) $ config ['max_retries ' ]);
550+
551+ if ($ config ['max_retries ' ] === 0 ) {
552+ return null ;
553+ }
554+
555+ $ config ['delay ' ] = $ this ->normalizeRetryDelay ($ config ['delay ' ]);
556+ $ config ['max_delay ' ] = max (0 , (int ) $ config ['max_delay ' ]);
557+ $ config ['status_codes ' ] = array_map (intval (...), (array ) $ config ['status_codes ' ]);
558+ $ config ['curl_errors ' ] = (bool ) $ config ['curl_errors ' ];
559+ $ config ['respect_retry_after ' ] = (bool ) $ config ['respect_retry_after ' ];
560+
561+ return $ config ;
562+ }
563+
564+ /**
565+ * Normalizes the retry delay setting.
566+ *
567+ * @return int|list<int>
568+ */
569+ protected function normalizeRetryDelay (mixed $ delay ): array |int
570+ {
571+ if (is_array ($ delay )) {
572+ return array_map (static fn ($ value ): int => max (0 , (int ) $ value ), $ delay );
573+ }
574+
575+ return max (0 , (int ) $ delay );
576+ }
577+
578+ /**
579+ * Determines whether a response should be retried.
580+ *
581+ * @param array<string, bool|int|list<int>> $retry
582+ */
583+ protected function shouldRetryResponse (ResponseInterface $ response , array $ retry , int $ attempt ): bool
584+ {
585+ if ($ attempt >= $ retry ['max_retries ' ]) {
586+ return false ;
587+ }
588+
589+ return in_array ($ response ->getStatusCode (), $ retry ['status_codes ' ], true );
590+ }
591+
592+ /**
593+ * Determines whether a cURL error should be retried.
594+ *
595+ * @param array<string, bool|int|list<int>> $retry
596+ */
597+ protected function shouldRetryCurlError (array $ retry , int $ attempt ): bool
598+ {
599+ if ($ attempt >= $ retry ['max_retries ' ] || $ retry ['curl_errors ' ] === false ) {
600+ return false ;
601+ }
602+
603+ return in_array ($ this ->lastCurlError , $ this ->transientCurlErrors , true );
604+ }
605+
606+ /**
607+ * Returns the delay before the next retry attempt.
608+ *
609+ * @param array<string, bool|int|list<int>> $retry
610+ */
611+ protected function getRetryDelay (array $ retry , int $ attempt , ?ResponseInterface $ response = null ): int
612+ {
613+ if ($ response instanceof ResponseInterface && $ retry ['respect_retry_after ' ] === true ) {
614+ $ retryAfter = $ this ->getRetryAfterDelay ($ response );
615+
616+ if ($ retryAfter !== null ) {
617+ return $ this ->limitRetryDelay ($ retryAfter * 1000 , $ retry );
618+ }
619+ }
620+
621+ $ delay = $ retry ['delay ' ];
622+
623+ if (is_array ($ delay )) {
624+ $ lastDelay = end ($ delay );
625+
626+ return $ this ->limitRetryDelay ((int ) ($ delay [$ attempt ] ?? ($ lastDelay !== false ? $ lastDelay : 0 )), $ retry );
627+ }
628+
629+ return $ this ->limitRetryDelay ((int ) $ delay , $ retry );
630+ }
631+
632+ /**
633+ * Caps the retry delay when configured.
634+ *
635+ * @param array<string, bool|int|list<int>> $retry
636+ */
637+ protected function limitRetryDelay (int $ delay , array $ retry ): int
638+ {
639+ $ maxDelay = (int ) $ retry ['max_delay ' ];
640+
641+ if ($ maxDelay === 0 ) {
642+ return $ delay ;
643+ }
644+
645+ return min ($ delay , $ maxDelay );
646+ }
647+
648+ /**
649+ * Returns the delay from a Retry-After header in seconds.
650+ */
651+ protected function getRetryAfterDelay (ResponseInterface $ response ): ?int
652+ {
653+ $ retryAfter = $ response ->getHeaderLine ('Retry-After ' );
654+
655+ if ($ retryAfter === '' ) {
656+ return null ;
657+ }
658+
659+ if (ctype_digit ($ retryAfter )) {
660+ return (int ) $ retryAfter ;
661+ }
662+
663+ $ timestamp = strtotime ($ retryAfter );
664+
665+ if ($ timestamp === false ) {
666+ return null ;
667+ }
668+
669+ return max (0 , $ timestamp - time ());
670+ }
671+
672+ /**
673+ * Sleeps for the configured number of seconds.
674+ */
675+ protected function sleep (float $ seconds ): void
676+ {
677+ usleep ((int ) ($ seconds * 1_000_000 ));
678+ }
679+
433680 /**
434681 * Adds $this->headers to the cURL request.
435682 */
@@ -731,6 +978,8 @@ protected function sendRequest(array $curlOptions = []): string
731978 $ output = curl_exec ($ ch );
732979
733980 if ($ output === false ) {
981+ $ this ->lastCurlError = curl_errno ($ ch );
982+
734983 throw HTTPException::forCurlError ((string ) curl_errno ($ ch ), curl_error ($ ch ));
735984 }
736985
0 commit comments