Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.14% covered (success)
97.14%
34 / 35
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
RetryMiddleware
97.14% covered (success)
97.14%
34 / 35
75.00% covered (warning)
75.00%
3 / 4
14
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decider
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 delay
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 retryAfterMillis
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace ForgeDashboard\Retry;
6
7use DateTimeImmutable;
8use DateTimeZone;
9use GuzzleHttp\Exception\ConnectException;
10use Psr\Http\Message\RequestInterface;
11use 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 */
20final 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}