Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.08% covered (success)
81.08%
60 / 74
52.38% covered (warning)
52.38%
11 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
RetryMiddleware
81.08% covered (success)
81.08%
60 / 74
52.38% covered (warning)
52.38%
11 / 21
52.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
1.00
 setMaxRetries
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getMaxRetries
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setRetryableMethods
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRetryableMethods
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setRetryableStatusCodes
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRetryableStatusCodes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setShouldRetryException
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setBaseDelay
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getBaseDelay
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setMaxDelay
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMaxDelay
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setOnRetry
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setSleeper
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
11
 wait
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 isMethodRetryable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canRetryBody
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 rewindBody
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 computeDelay
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 parseRetryAfter
80.00% covered (success)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http\Client\Middleware;
16
17use Pop\Http\Client\Handler\Exception as HandlerException;
18use Psr\Http\Message\RequestInterface;
19use Psr\Http\Message\ResponseInterface;
20
21/**
22 * Retry/backoff middleware - retries a request on transient network exceptions
23 * and/or specific response status codes, with exponential backoff and full
24 * jitter between attempts. Honors a Retry-After response header over the
25 * computed delay when present. Skips retrying entirely when the request has
26 * a non-seekable body, since a partially-consumed stream can't be safely
27 * resent.
28 *
29 * @category   Pop
30 * @package    Pop\Http
31 * @author     Nick Sagona, III <nick@popphp.org>
32 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
33 * @license    https://www.popphp.org/license     New BSD License
34 * @version    6.0.0
35 */
36class RetryMiddleware implements MiddlewareInterface
37{
38
39    /**
40     * Maximum number of retries (attempts beyond the first try)
41     * @var int
42     */
43    protected int $maxRetries = 3;
44
45    /**
46     * HTTP methods eligible for retry (uppercase)
47     * @var array
48     */
49    protected array $retryableMethods = ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'];
50
51    /**
52     * Response status codes eligible for retry
53     * @var array
54     */
55    protected array $retryableStatusCodes = [429, 502, 503, 504];
56
57    /**
58     * Predicate deciding whether a caught exception is retryable
59     * callable(\Throwable $exception): bool
60     * @var callable
61     */
62    protected $shouldRetryException;
63
64    /**
65     * Base delay, in seconds, for the exponential backoff calculation
66     * @var float
67     */
68    protected float $baseDelay = 0.1;
69
70    /**
71     * Maximum delay, in seconds, before jitter is applied
72     * @var float
73     */
74    protected float $maxDelay = 10.0;
75
76    /**
77     * Optional observability callback, fired before each retry's sleep
78     * callable(int $attempt, RequestInterface $request, ?ResponseInterface $response, ?\Throwable $exception, float $delaySeconds): void
79     * @var ?callable
80     */
81    protected $onRetry = null;
82
83    /**
84     * Sleep function, callable(float $seconds): void - defaults to a usleep()
85     * wrapper, overridable so tests don't have to actually sleep
86     * @var callable
87     */
88    protected $sleeper;
89
90    /**
91     * Constructor
92     *
93     * @param int $maxRetries
94     */
95    public function __construct(int $maxRetries = 3)
96    {
97        $this->maxRetries           = $maxRetries;
98        $this->shouldRetryException = function (\Throwable $exception): bool {
99            return $exception instanceof HandlerException;
100        };
101        $this->sleeper = function (float $seconds): void {
102            usleep((int)round($seconds * 1_000_000));
103        };
104    }
105
106    /**
107     * Set the maximum number of retries
108     *
109     * @param  int $maxRetries
110     * @return RetryMiddleware
111     */
112    public function setMaxRetries(int $maxRetries): RetryMiddleware
113    {
114        $this->maxRetries = $maxRetries;
115        return $this;
116    }
117
118    /**
119     * Get the maximum number of retries
120     *
121     * @return int
122     */
123    public function getMaxRetries(): int
124    {
125        return $this->maxRetries;
126    }
127
128    /**
129     * Set the HTTP methods eligible for retry
130     *
131     * @param  array $methods
132     * @return RetryMiddleware
133     */
134    public function setRetryableMethods(array $methods): RetryMiddleware
135    {
136        $this->retryableMethods = array_map('strtoupper', $methods);
137        return $this;
138    }
139
140    /**
141     * Get the HTTP methods eligible for retry
142     *
143     * @return array
144     */
145    public function getRetryableMethods(): array
146    {
147        return $this->retryableMethods;
148    }
149
150    /**
151     * Set the response status codes eligible for retry
152     *
153     * @param  array $statusCodes
154     * @return RetryMiddleware
155     */
156    public function setRetryableStatusCodes(array $statusCodes): RetryMiddleware
157    {
158        $this->retryableStatusCodes = $statusCodes;
159        return $this;
160    }
161
162    /**
163     * Get the response status codes eligible for retry
164     *
165     * @return array
166     */
167    public function getRetryableStatusCodes(): array
168    {
169        return $this->retryableStatusCodes;
170    }
171
172    /**
173     * Set the predicate deciding whether a caught exception is retryable
174     *
175     * @param  callable $predicate  callable(\Throwable $exception): bool
176     * @return RetryMiddleware
177     */
178    public function setShouldRetryException(callable $predicate): RetryMiddleware
179    {
180        $this->shouldRetryException = $predicate;
181        return $this;
182    }
183
184    /**
185     * Set the base delay, in seconds, for the exponential backoff calculation
186     *
187     * @param  float $seconds
188     * @return RetryMiddleware
189     */
190    public function setBaseDelay(float $seconds): RetryMiddleware
191    {
192        $this->baseDelay = $seconds;
193        return $this;
194    }
195
196    /**
197     * Get the base delay, in seconds
198     *
199     * @return float
200     */
201    public function getBaseDelay(): float
202    {
203        return $this->baseDelay;
204    }
205
206    /**
207     * Set the maximum delay, in seconds, before jitter is applied
208     *
209     * @param  float $seconds
210     * @return RetryMiddleware
211     */
212    public function setMaxDelay(float $seconds): RetryMiddleware
213    {
214        $this->maxDelay = $seconds;
215        return $this;
216    }
217
218    /**
219     * Get the maximum delay, in seconds
220     *
221     * @return float
222     */
223    public function getMaxDelay(): float
224    {
225        return $this->maxDelay;
226    }
227
228    /**
229     * Set the observability callback, fired before each retry's sleep
230     *
231     * @param  callable $callback  callable(int $attempt, RequestInterface $request, ?ResponseInterface $response, ?\Throwable $exception, float $delaySeconds): void
232     * @return RetryMiddleware
233     */
234    public function setOnRetry(callable $callback): RetryMiddleware
235    {
236        $this->onRetry = $callback;
237        return $this;
238    }
239
240    /**
241     * Set the sleep function - test-only override point, defaults to a usleep() wrapper
242     *
243     * @param  callable $sleeper  callable(float $seconds): void
244     * @return RetryMiddleware
245     */
246    public function setSleeper(callable $sleeper): RetryMiddleware
247    {
248        $this->sleeper = $sleeper;
249        return $this;
250    }
251
252    /**
253     * Process the request, retrying on transient failures per the configured policy
254     *
255     * @param  RequestInterface        $request
256     * @param  RequestHandlerInterface $handler
257     * @return ResponseInterface
258     */
259    public function process(RequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
260    {
261        $attempt = 0;
262
263        while (true) {
264            try {
265                $response = $handler->handle($request);
266            } catch (\Throwable $exception) {
267                if (($attempt >= $this->maxRetries) || (!$this->isMethodRetryable($request)) ||
268                    (!($this->shouldRetryException)($exception)) || (!$this->canRetryBody($request))) {
269                    throw $exception;
270                }
271
272                $this->wait($request, $attempt, null, $exception);
273                $attempt++;
274                continue;
275            }
276
277            if (($attempt >= $this->maxRetries) || (!$this->isMethodRetryable($request)) ||
278                (!in_array($response->getStatusCode(), $this->retryableStatusCodes, true)) ||
279                (!$this->canRetryBody($request))) {
280                return $response;
281            }
282
283            $this->wait($request, $attempt, $response, null);
284            $attempt++;
285        }
286    }
287
288    /**
289     * Compute the delay, fire the onRetry callback, sleep, and rewind the request body
290     *
291     * @param  RequestInterface   $request
292     * @param  int                $attempt
293     * @param  ?ResponseInterface $response
294     * @param  ?\Throwable        $exception
295     * @return void
296     */
297    protected function wait(RequestInterface $request, int $attempt, ?ResponseInterface $response, ?\Throwable $exception): void
298    {
299        $delay = $this->computeDelay($response, $attempt);
300
301        if ($this->onRetry !== null) {
302            ($this->onRetry)($attempt + 1, $request, $response, $exception, $delay);
303        }
304
305        ($this->sleeper)($delay);
306        $this->rewindBody($request);
307    }
308
309    /**
310     * Determine if the request's method is eligible for retry
311     *
312     * @param  RequestInterface $request
313     * @return bool
314     */
315    protected function isMethodRetryable(RequestInterface $request): bool
316    {
317        return in_array(strtoupper($request->getMethod()), $this->retryableMethods, true);
318    }
319
320    /**
321     * Determine if the request's body can be safely resent - true when
322     * there's no underlying stream to worry about, or the body is seekable.
323     *
324     * Deliberately does not use getSize() === 0 as the "no body" signal:
325     * a real, statable stream (e.g. a pipe, socket, or php://stdin) can
326     * legitimately report a size of 0 while still being non-rewindable, and
327     * that must fall through to the isSeekable() check rather than being
328     * waved through. A Body with no stream attached at all has no metadata
329     * (getMetadata() returns []), which is what actually means "no body".
330     *
331     * @param  RequestInterface $request
332     * @return bool
333     */
334    protected function canRetryBody(RequestInterface $request): bool
335    {
336        $body = $request->getBody();
337
338        return (($body->getMetadata() === []) || $body->isSeekable());
339    }
340
341    /**
342     * Rewind the request's body, if it has an underlying stream. Uses the
343     * same "no stream attached" signal as canRetryBody() (see its docblock)
344     * so the two never drift out of sync.
345     *
346     * @param  RequestInterface $request
347     * @return void
348     */
349    protected function rewindBody(RequestInterface $request): void
350    {
351        $body = $request->getBody();
352        if ($body->getMetadata() !== []) {
353            $body->rewind();
354        }
355    }
356
357    /**
358     * Compute the delay before the next attempt - honors a Retry-After response
359     * header over the computed exponential-with-jitter delay when present, clamped
360     * to the configured maximum delay
361     *
362     * @param  ?ResponseInterface $response
363     * @param  int                $attempt
364     * @return float
365     */
366    protected function computeDelay(?ResponseInterface $response, int $attempt): float
367    {
368        if ($response !== null) {
369            $header = $response->getHeaderLine('Retry-After');
370            if ($header !== '') {
371                $retryAfter = $this->parseRetryAfter($header);
372                if ($retryAfter !== null) {
373                    return min($this->maxDelay, $retryAfter);
374                }
375            }
376        }
377
378        $exponential = $this->baseDelay * (2 ** $attempt);
379        $capped      = min($this->maxDelay, $exponential);
380
381        return $capped * (mt_rand() / mt_getrandmax());
382    }
383
384    /**
385     * Parse a Retry-After header value - either an integer number of seconds,
386     * or an HTTP-date to wait until (RFC 9110)
387     *
388     * @param  string $value
389     * @return ?float
390     */
391    protected function parseRetryAfter(string $value): ?float
392    {
393        $trimmed = trim($value);
394
395        if ($trimmed === '') {
396            return null;
397        }
398
399        if (ctype_digit($trimmed)) {
400            return (float)$trimmed;
401        }
402
403        $timestamp = strtotime($trimmed);
404        if ($timestamp === false) {
405            return null;
406        }
407
408        $delay = $timestamp - time();
409
410        return ($delay > 0) ? (float)$delay : 0.0;
411    }
412
413}