Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.14% |
34 / 35 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
| RetryMiddleware | |
97.14% |
34 / 35 |
|
75.00% |
3 / 4 |
14 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| decider | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
5.01 | |||
| delay | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| retryAfterMillis | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace ForgeDashboard\Retry; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use DateTimeZone; |
| 9 | use GuzzleHttp\Exception\ConnectException; |
| 10 | use Psr\Http\Message\RequestInterface; |
| 11 | use Psr\Http\Message\ResponseInterface; |
| 12 | |
| 13 | /** |
| 14 | * Builds the decider/delay pair for GuzzleHttp\Middleware::retry -- |
| 15 | * exponential backoff with jitter on a 429 or 5xx, honoring a |
| 16 | * server-sent Retry-After (seconds or an HTTP-date, RFC 9110 section |
| 17 | * 10.2.3) ahead of the computed backoff, and never retrying any other |
| 18 | * 4xx (rules/sdk-generation.md's "Client shape"). |
| 19 | */ |
| 20 | final readonly class RetryMiddleware |
| 21 | { |
| 22 | public function __construct( |
| 23 | private int $maxRetries = 3, |
| 24 | private float $baseDelaySeconds = 0.25, |
| 25 | ) {} |
| 26 | |
| 27 | /** |
| 28 | * Guzzle calls this with the raw rejection reason as its fourth |
| 29 | * argument (untyped -- GuzzleHttp\RetryMiddleware itself only |
| 30 | * documents it as `mixed`), which in practice is always a Throwable |
| 31 | * for an HTTP middleware chain -- a ConnectException for a |
| 32 | * network-level failure before any response arrived, or nothing at |
| 33 | * all when a response (even an error one) came back, since the retry |
| 34 | * middleware sits closer to the transport than Guzzle's http_errors |
| 35 | * middleware and sees the raw response first. |
| 36 | * |
| 37 | * @return callable(int, RequestInterface, ResponseInterface|null=, mixed=): bool |
| 38 | */ |
| 39 | public function decider(): callable |
| 40 | { |
| 41 | return function ( |
| 42 | int $retries, |
| 43 | RequestInterface $request, |
| 44 | ?ResponseInterface $response = null, |
| 45 | mixed $exception = null, |
| 46 | ): bool { |
| 47 | if ($retries >= $this->maxRetries) { |
| 48 | return false; |
| 49 | } |
| 50 | |
| 51 | if ($exception instanceof ConnectException) { |
| 52 | // A network-level failure (connection reset, timeout) is |
| 53 | // retry-worthy the same as a 5xx. |
| 54 | return true; |
| 55 | } |
| 56 | |
| 57 | if (! $response instanceof ResponseInterface) { |
| 58 | return false; |
| 59 | } |
| 60 | |
| 61 | $status = $response->getStatusCode(); |
| 62 | |
| 63 | return $status === 429 || $status >= 500; |
| 64 | }; |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * @return callable(int, ResponseInterface|null=): int Delay in |
| 69 | * milliseconds -- the unit GuzzleHttp\Middleware::retry expects. |
| 70 | * Guzzle itself calls this with a third $request argument too; |
| 71 | * it's irrelevant to the delay calculation and safely dropped. |
| 72 | */ |
| 73 | public function delay(): callable |
| 74 | { |
| 75 | return function (int $retries, ?ResponseInterface $response = null): int { |
| 76 | if ($response instanceof ResponseInterface) { |
| 77 | $retryAfter = $this->retryAfterMillis($response); |
| 78 | if ($retryAfter !== null) { |
| 79 | return $retryAfter; |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | $backoffMillis = (int) ($this->baseDelaySeconds * 1000 * (2 ** $retries)); |
| 84 | $jitter = random_int(0, $backoffMillis); |
| 85 | |
| 86 | return intdiv($backoffMillis, 2) + intdiv($jitter, 2); |
| 87 | }; |
| 88 | } |
| 89 | |
| 90 | private function retryAfterMillis(ResponseInterface $response): ?int |
| 91 | { |
| 92 | $header = $response->getHeaderLine('Retry-After'); |
| 93 | if ($header === '') { |
| 94 | return null; |
| 95 | } |
| 96 | |
| 97 | if (ctype_digit($header)) { |
| 98 | return ((int) $header) * 1000; |
| 99 | } |
| 100 | |
| 101 | // RFC 9110 section 10.2.3's IMF-fixdate, spelled out literally |
| 102 | // rather than via the DateTimeInterface::RFC7231 constant -- that |
| 103 | // constant is deprecated as of PHP 8.5 (it silently assumes GMT |
| 104 | // and ignores the actual timezone), and an HTTP-date is always |
| 105 | // GMT by definition anyway. |
| 106 | $when = DateTimeImmutable::createFromFormat('D, d M Y H:i:s \G\M\T', $header, new DateTimeZone('GMT')); |
| 107 | if ($when === false) { |
| 108 | return null; |
| 109 | } |
| 110 | |
| 111 | $seconds = $when->getTimestamp() - time(); |
| 112 | |
| 113 | return $seconds > 0 ? $seconds * 1000 : 0; |
| 114 | } |
| 115 | } |