Skip to content

Commit af9bc9e

Browse files
committed
feat: opt-in HTTP client resilience layer (retry/backoff/jitter)
Client::withRetryPolicy()/setRetryPolicy()/getRetryPolicy() enable automatic retry of transient failures (connection errors, timeouts, 408/425/429/5xx) with exponential backoff, bounded jitter, a max-attempts cap and Retry-After support. New InitPHP\HTTP\Client\Retry\ namespace; the single cURL attempt is exposed as a protected Client::transport() seam. Backward compatible: with no policy the client stays single-attempt and still returns (never throws) 4xx/5xx per PSR-18.
1 parent a962f11 commit af9bc9e

14 files changed

Lines changed: 1204 additions & 3 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- **Content-Type-aware body parsing** in `createFromGlobals` (JSON / urlencoded / multipart).
1515
- **nginx + php-fpm header fallback** in `createFromGlobals` for environments without `apache_request_headers()`.
1616
- **`Client::withTimeout()` / `withConnectTimeout()` / `withFollowRedirects()` / `withCurlOptions()`** for production-grade configuration.
17+
- **Opt-in HTTP client resilience layer**`Client::withRetryPolicy()` / `setRetryPolicy()` / `getRetryPolicy()` enable automatic retry of transient failures (connection errors, timeouts, and the retryable status set `408/425/429/500/502/503/504`) with exponential backoff, bounded jitter, a max-attempts cap, and `Retry-After` support. New `InitPHP\HTTP\Client\Retry\` namespace: `RetryPolicy` (config value object), `Backoff` (exponential + equal-jitter calculator), `RetryAfter` (RFC 7231 header parser), `Sleeper`/`SleeperInterface` (injectable pacing). The single cURL attempt is exposed as a protected `Client::transport()` seam for testing/overriding. **Backward compatible: with no policy attached the client stays single-attempt** and still returns (never throws) 4xx/5xx per PSR-18.
1718
- **`Facadable` trait and `FacadableInterface`** — canonical names replacing the misspelled `Facadeble[Interface]` (old names kept as `@deprecated` aliases).
1819
- **`docs/` directory** with PSR-7/17/18 guides, emitter walk-throughs, recipes, status-code reference and an upgrade guide.
1920
- **Comprehensive `tests/Unit/` suite** complementing the upstream PSR integration tests.

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,51 @@ $response = $client->post('https://api.example.com/users', '{"name":"Ada"}', [
146146

147147
PSR-18 contract is honoured: **4xx/5xx responses are returned, not thrown**. Only transport failures raise `Psr\Http\Client\NetworkExceptionInterface`.
148148

149+
#### Resilience: retry with exponential backoff + jitter
150+
151+
The client is single-attempt by default. Attach a `RetryPolicy` to opt in to
152+
automatic retries of *transient* failures — connection errors, timeouts and the
153+
retryable HTTP status set (`408, 425, 429, 500, 502, 503, 504` by default) — with
154+
exponential backoff, bounded jitter, a hard max-attempts cap, and `Retry-After`
155+
support:
156+
157+
```php
158+
use InitPHP\HTTP\Client\Client;
159+
use InitPHP\HTTP\Client\Retry\RetryPolicy;
160+
161+
$client = (new Client())->withRetryPolicy(new RetryPolicy(
162+
maxAttempts: 4, // 1 initial try + up to 3 retries
163+
baseDelay: 0.1, // first backoff interval, seconds
164+
multiplier: 2.0, // 0.1s, 0.2s, 0.4s, ...
165+
maxDelay: 30.0, // cap on any single interval
166+
jitter: 0.5, // up to 50% random reduction (anti thundering-herd)
167+
));
168+
169+
// Every verb helper inherits the policy transparently.
170+
$response = $client->get('https://api.example.com/flaky');
171+
```
172+
173+
Behaviour and backward-compatibility:
174+
175+
- **Default (no policy) = exactly one attempt** — identical to previous releases.
176+
- A non-retryable `4xx`/`5xx` response is still **returned, never thrown** (PSR-18).
177+
- A retryable response that exhausts the attempt cap is returned as-is (the last
178+
response); a transport exception that exhausts the cap is re-thrown.
179+
- A parseable `Retry-After` header (delay-seconds or HTTP-date) on a retryable
180+
response **overrides** the computed backoff unless you disable it.
181+
- The retryable status set, whether transport exceptions are retried, and
182+
`Retry-After` handling are all configurable on `RetryPolicy`.
183+
184+
```php
185+
// Customise: only retry 429/503, never on transport exceptions, ignore Retry-After.
186+
new RetryPolicy(
187+
maxAttempts: 5,
188+
retryOnException: false,
189+
retryableStatusCodes: [429, 503],
190+
respectRetryAfter: false,
191+
);
192+
```
193+
149194
### Emitting a response (SAPI)
150195

151196
```php

src/Client/Client.php

Lines changed: 193 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
use \InitPHP\HTTP\Message\{Request, Stream, Response};
1818
use \Psr\Http\Message\{RequestInterface, ResponseInterface, StreamInterface};
1919
use \InitPHP\HTTP\Client\Exceptions\{ClientException, NetworkException, RequestException};
20+
use \InitPHP\HTTP\Client\Retry\{Backoff, RetryAfter, RetryPolicy, Sleeper, SleeperInterface};
21+
use \Psr\Http\Client\NetworkExceptionInterface;
2022

2123
use const CASE_LOWER;
2224
use const FILTER_VALIDATE_URL;
@@ -81,6 +83,27 @@ class Client implements \Psr\Http\Client\ClientInterface
8183
*/
8284
protected int $maxRedirects = 10;
8385

86+
/**
87+
* Opt-in retry policy. When null (the default), {@see Client::sendRequest()}
88+
* makes exactly one transport attempt and returns/throws as before — fully
89+
* backward-compatible. Attaching a policy turns on retry-with-backoff.
90+
*/
91+
protected ?RetryPolicy $retryPolicy = null;
92+
93+
/**
94+
* Pacing strategy used to wait between retry attempts. Injectable so the
95+
* retry loop can be exercised without real wall-clock delays in tests.
96+
*/
97+
protected SleeperInterface $sleeper;
98+
99+
/**
100+
* Optional jitter randomness source (a callable returning a float in
101+
* [0, 1)) forwarded to {@see Backoff}. null defers to Backoff's default.
102+
*
103+
* @var (callable(): float)|null
104+
*/
105+
protected $jitterRandomizer = null;
106+
84107
/**
85108
* Construct a client and assert ext-curl is loaded; the cURL extension
86109
* is the only transport this client speaks.
@@ -92,6 +115,7 @@ public function __construct()
92115
if (!extension_loaded('curl')) {
93116
throw new ClientException('The CURL extension must be installed.');
94117
}
118+
$this->sleeper = new Sleeper();
95119
}
96120

97121
/**
@@ -247,6 +271,91 @@ public function withFollowRedirects(bool $follow, int $max = 10): self
247271
return (clone $this)->setFollowRedirects($follow, $max);
248272
}
249273

274+
/**
275+
* Attach (or clear) the retry-with-backoff policy in place. Passing null
276+
* restores the default single-attempt behaviour.
277+
*
278+
* @param RetryPolicy|null $policy
279+
* @return $this
280+
*/
281+
public function setRetryPolicy(?RetryPolicy $policy): self
282+
{
283+
$this->retryPolicy = $policy;
284+
285+
return $this;
286+
}
287+
288+
/**
289+
* Return a clone of the client with the retry policy replaced (or cleared).
290+
*
291+
* @param RetryPolicy|null $policy
292+
* @return $this
293+
*/
294+
public function withRetryPolicy(?RetryPolicy $policy): self
295+
{
296+
return (clone $this)->setRetryPolicy($policy);
297+
}
298+
299+
/**
300+
* Return the active retry policy, or null when retries are disabled.
301+
*
302+
* @return RetryPolicy|null
303+
*/
304+
public function getRetryPolicy(): ?RetryPolicy
305+
{
306+
return $this->retryPolicy;
307+
}
308+
309+
/**
310+
* Replace the inter-attempt pacing strategy (in place). Primarily a test
311+
* seam: inject a no-op sleeper to exercise the retry loop instantly.
312+
*
313+
* @param SleeperInterface $sleeper
314+
* @return $this
315+
*/
316+
public function setSleeper(SleeperInterface $sleeper): self
317+
{
318+
$this->sleeper = $sleeper;
319+
320+
return $this;
321+
}
322+
323+
/**
324+
* Return a clone of the client with the pacing strategy replaced.
325+
*
326+
* @param SleeperInterface $sleeper
327+
* @return $this
328+
*/
329+
public function withSleeper(SleeperInterface $sleeper): self
330+
{
331+
return (clone $this)->setSleeper($sleeper);
332+
}
333+
334+
/**
335+
* Replace the jitter randomness source (in place) forwarded to the backoff
336+
* calculator. Pass null to defer to the default mt_rand()-based source.
337+
*
338+
* @param (callable(): float)|null $randomizer Returns a float in [0, 1).
339+
* @return $this
340+
*/
341+
public function setJitterRandomizer(?callable $randomizer): self
342+
{
343+
$this->jitterRandomizer = $randomizer;
344+
345+
return $this;
346+
}
347+
348+
/**
349+
* Return a clone of the client with the jitter randomness source replaced.
350+
*
351+
* @param (callable(): float)|null $randomizer
352+
* @return $this
353+
*/
354+
public function withJitterRandomizer(?callable $randomizer): self
355+
{
356+
return (clone $this)->setJitterRandomizer($randomizer);
357+
}
358+
250359
/**
251360
* Dispatch a request specified as a $url + loose options array. Keys
252361
* are matched case-insensitively and may include `method`, `data`,
@@ -373,17 +482,98 @@ public function head(string $url, $body = null, array $headers = [], string $ver
373482

374483
/**
375484
* Execute the supplied PSR-7 request and return the PSR-7 response.
485+
*
486+
* When no {@see RetryPolicy} is attached (the default), this performs a
487+
* single transport attempt — behaviour identical to previous releases. When
488+
* a policy is attached, transient failures (retryable status codes and, if
489+
* enabled, transport exceptions) are retried up to the policy's
490+
* max-attempts cap, spaced by exponential backoff with jitter. A
491+
* `Retry-After` header on a retryable response overrides the computed delay
492+
* when the policy opts in. PSR-18 semantics are preserved: a non-retryable
493+
* 4xx/5xx response is returned, never thrown.
494+
*
495+
* @param RequestInterface $request
496+
* @return ResponseInterface
497+
* @throws ClientException When cURL cannot be initialised at all.
498+
* @throws RequestException When the request itself cannot be marshalled (invalid URL, body coercion failure).
499+
* @throws NetworkException When cURL reports a transport-level failure and either retries are disabled or the cap is reached.
500+
*/
501+
public function sendRequest(RequestInterface $request): ResponseInterface
502+
{
503+
$policy = $this->retryPolicy;
504+
if ($policy === null) {
505+
return $this->transport($request);
506+
}
507+
508+
$backoff = new Backoff($policy, $this->jitterRandomizer);
509+
$attempt = 0;
510+
511+
while (true) {
512+
$attempt++;
513+
try {
514+
$response = $this->transport($request);
515+
} catch (NetworkExceptionInterface $e) {
516+
// Transport-level failure (DNS, TCP, TLS, timeout). Retry only
517+
// when the policy permits it and the attempt cap is not reached;
518+
// otherwise rethrow so the caller sees the original exception.
519+
if (!$policy->isRetryOnException() || !$policy->shouldRetry($attempt)) {
520+
throw $e;
521+
}
522+
$this->sleeper->sleep($backoff->delayFor($attempt));
523+
continue;
524+
}
525+
526+
// A successful transport call with a non-retryable status — or one
527+
// that has exhausted the attempt budget — is the final answer.
528+
if (!$policy->isRetryableStatus($response->getStatusCode())
529+
|| !$policy->shouldRetry($attempt)) {
530+
return $response;
531+
}
532+
533+
$this->sleeper->sleep($this->retryDelay($policy, $backoff, $response, $attempt));
534+
}
535+
}
536+
537+
/**
538+
* Resolve the delay before the next retry for a retryable *response*:
539+
* honour a parseable `Retry-After` header when the policy opts in,
540+
* otherwise fall back to the jittered exponential backoff for this attempt.
541+
*
542+
* @param RetryPolicy $policy
543+
* @param Backoff $backoff
544+
* @param ResponseInterface $response
545+
* @param int $attempt 1-based attempt index just completed.
546+
* @return float Delay in seconds.
547+
*/
548+
private function retryDelay(RetryPolicy $policy, Backoff $backoff, ResponseInterface $response, int $attempt): float
549+
{
550+
if ($policy->isRespectRetryAfter()) {
551+
$retryAfter = RetryAfter::fromResponse($response);
552+
if ($retryAfter !== null) {
553+
return $retryAfter;
554+
}
555+
}
556+
557+
return $backoff->delayFor($attempt);
558+
}
559+
560+
/**
561+
* Perform a single cURL transport attempt for $request and return the
562+
* PSR-7 response. This is the retry-free primitive that
563+
* {@see Client::sendRequest()} orchestrates; subclasses and tests can
564+
* override it to simulate transport behaviour without touching the network.
565+
*
376566
* The response body is wrapped in a php://temp-backed Stream so large
377-
* payloads spill to disk (cURL's 2 MiB threshold) instead of pinning
378-
* the full response into the process's resident memory.
567+
* payloads spill to disk (cURL's 2 MiB threshold) instead of pinning the
568+
* full response into the process's resident memory.
379569
*
380570
* @param RequestInterface $request
381571
* @return ResponseInterface
382572
* @throws ClientException When cURL cannot be initialised at all.
383573
* @throws RequestException When the request itself cannot be marshalled (invalid URL, body coercion failure).
384574
* @throws NetworkException When cURL reports a transport-level failure (DNS, TCP, TLS, timeout, ...).
385575
*/
386-
public function sendRequest(RequestInterface $request): ResponseInterface
576+
protected function transport(RequestInterface $request): ResponseInterface
387577
{
388578
$response = [
389579
'body' => '',

0 commit comments

Comments
 (0)