Skip to content

Commit 3a10470

Browse files
authored
feat: add CURLRequest retry option (#10157)
- Add retry support for failed HTTP responses - Support configurable attempts, delays, status codes, and Retry-After - Add opt-in retries for transient cURL errors - Document retry behavior and safety considerations - Add focused CURLRequest retry tests
1 parent f300ca0 commit 3a10470

6 files changed

Lines changed: 727 additions & 4 deletions

File tree

system/HTTP/CURLRequest.php

Lines changed: 254 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)