2020 - [ Default headers] ( #default-headers )
2121 - [ Setting the User-Agent] ( #setting-the-user-agent )
2222 - [ Error handling] ( #error-handling )
23+ - [ Retrying failed requests] ( #retrying-failed-requests )
24+ - [ Backoff policies] ( #backoff-policies )
25+ - [ Setting outbound headers] ( #setting-outbound-headers )
2326 - [ Configuring timeouts] ( #configuring-timeouts )
2427 - [ Testing with InMemoryTransport] ( #testing-with-inmemorytransport )
2528 - [ Extending with custom transports] ( #extending-with-custom-transports )
@@ -35,6 +38,9 @@ The library covers both sides of an HTTP exchange:
3538 outgoing ` ResponseInterface ` instances with cookies, cache-control, and status codes.
3639- ** Client side** (` TinyBlocks\Http\Client ` ) - composes outbound requests, sends them through a ` Transport ` port backed
3740 by any PSR-18 client, and exposes responses with typed body and header access.
41+ - ** Client resilience** (` TinyBlocks\Http\Client\Resilience ` ) - decorates any PSR-18 client with retries, backoff
42+ policies, and notification of failed attempts, measuring each attempt with
43+ [ tiny-blocks/time] ( https://github.com/tiny-blocks/time ) .
3844
3945Shared primitives at ` TinyBlocks\Http\ ` : ` Method ` , ` Code ` , ` Headers ` , ` Headerable ` , ` ContentType ` , ` MimeType ` ,
4046` Charset ` , ` Cookie ` , ` SameSite ` , ` CacheControl ` , ` ResponseCacheDirectives ` , ` Link ` , ` LinkRelation ` , ` UserAgent ` .
@@ -681,9 +687,151 @@ try {
681687| ` MalformedPath ` | Path attempts to escape the base URL (scheme, protocol-relative, control characters). |
682688| ` NoMoreResponses ` | ` InMemoryTransport ` exhausted (programmer error). |
683689| ` HttpConfigurationInvalid ` | Builder called without required dependencies. |
690+ | ` ClientNotConfigured ` | ` RetryingClientBuilder::build() ` called without a PSR-18 client. |
684691| ` SynthesizedResponseHasNoRaw ` | ` Response::raw() ` called on a response created via ` Response::with(...) ` . |
685692| ` HttpResponseUnsuccessful ` | ` Response::orFail() ` called on a non-2xx response. |
686693
694+ #### Retrying failed requests
695+
696+ ` RetryingClient ` is a PSR-18 decorator that retries transient failures. A network failure or a server error (HTTP 5xx)
697+ is retried until the attempt ceiling is reached, sleeping the configured [ backoff] ( #backoff-policies ) delay between
698+ attempts. A client error (HTTP 4xx) is never retried: the response is returned as is. Any other failure raised by the
699+ decorated client propagates immediately. When the attempts are exhausted, the last response is returned or the last
700+ exception is rethrown. The ` maxAttempts ` ceiling counts the first attempt, so ` maxAttempts: 2 ` means one retry.
701+
702+ Every failed attempt, the final one included, is reported to an optional ` RetryListener ` with the elapsed interval of
703+ the attempt (an ` Elapsed ` from [ tiny-blocks/time] ( https://github.com/tiny-blocks/time ) ), its ` AttemptOutcome `
704+ classification, the request, and the attempt number. Successful attempts are never reported.
705+
706+ | ` AttemptOutcome ` | Trigger | Retried |
707+ | ------------------------------------| ---------------------------------------------------| ---------|
708+ | ` AttemptOutcome::TIMEOUT ` | Network failure whose message mentions a timeout, or an HTTP 408 or 504 response. | Yes |
709+ | ` AttemptOutcome::CONNECTION_RESET ` | Any other network failure. | Yes |
710+ | ` AttemptOutcome::SERVER_ERROR ` | Any other HTTP 5xx response, non-RFC codes included. | Yes |
711+ | ` AttemptOutcome::CLIENT_ERROR ` | Any other HTTP 4xx response. | No |
712+
713+ Assemble the decorator with the fluent builder returned by ` RetryingClient::create() ` . Only the PSR-18 client is
714+ required, and ` build() ` raises ` ClientNotConfigured ` without one. Every other collaborator falls back to an
715+ opinionated default: an ` ExponentialBackoff ` with random jitter, an attempt ceiling of three, the system monotonic
716+ clock and sleeper, and a listener that ignores failures.
717+
718+ ``` php
719+ <?php
720+
721+ declare(strict_types=1);
722+
723+ use GuzzleHttp\Client;
724+ use Psr\Http\Message\RequestInterface;
725+ use Psr\Log\LoggerInterface;
726+ use TinyBlocks\Http\Client\Resilience\AttemptOutcome;
727+ use TinyBlocks\Http\Client\Resilience\FixedDelay;
728+ use TinyBlocks\Http\Client\Resilience\RetryListener;
729+ use TinyBlocks\Http\Client\Resilience\RetryingClient;
730+ use TinyBlocks\Time\Elapsed;
731+
732+ final readonly class LoggingRetryListener implements RetryListener
733+ {
734+ public function __construct(private LoggerInterface $logger)
735+ {
736+ }
737+
738+ public function attemptFailed(
739+ Elapsed $elapsed,
740+ AttemptOutcome $outcome,
741+ RequestInterface $request,
742+ int $attemptNumber
743+ ): void {
744+ $this->logger->warning('http_attempt_failed', [
745+ 'target' => (string)$request->getUri(),
746+ 'outcome' => $outcome->value,
747+ 'elapsed_ms' => $elapsed->toMilliseconds(),
748+ 'attempt_number' => $attemptNumber
749+ ]);
750+ }
751+ }
752+
753+ # One retry after a fixed 500 ms delay.
754+ $client = RetryingClient::create()
755+ ->withClient(client: new Client(config: ['timeout' => 10, 'connect_timeout' => 5]))
756+ ->withBackoff(backoff: FixedDelay::ofMicroseconds(microseconds: 500000))
757+ ->withListener(listener: new LoggingRetryListener(logger: $logger))
758+ ->withMaxAttempts(maxAttempts: 2)
759+ ->build();
760+
761+ $response = $client->sendRequest($request);
762+ ```
763+
764+ The listener is optional. When omitted, failed attempts are silently ignored. Because ` RetryingClient ` is itself a
765+ PSR-18 client, it plugs into anything that accepts one, including the library's own transport:
766+
767+ ``` php
768+ <?php
769+
770+ declare(strict_types=1);
771+
772+ use GuzzleHttp\Psr7\HttpFactory;
773+ use TinyBlocks\Http\Client\Transports\NetworkTransport;
774+ use TinyBlocks\Http\Http;
775+
776+ $http = Http::with(
777+ baseUrl: 'https://api.example.com',
778+ transport: NetworkTransport::with(client: $client, factory: new HttpFactory())
779+ );
780+ ```
781+
782+ #### Backoff policies
783+
784+ ` Backoff ` computes the delay, in microseconds, slept before the next attempt. Two implementations ship with the
785+ library. Implement the interface for any other curve.
786+
787+ ` FixedDelay ` waits the same delay before every retry:
788+
789+ ``` php
790+ FixedDelay::ofMicroseconds(microseconds: 500000); # always 500 ms
791+ ```
792+
793+ ` ExponentialBackoff ` doubles a base delay of 100 ms on every attempt and spreads it with a uniformly random jitter of
794+ up to 30 percent of that value in either direction, keeping concurrent clients from retrying in lockstep against a
795+ recovering dependency:
796+
797+ ``` php
798+ <?php
799+
800+ declare(strict_types=1);
801+
802+ use Random\Randomizer;
803+ use TinyBlocks\Http\Client\Resilience\ExponentialBackoff;
804+
805+ $backoff = ExponentialBackoff::with(randomizer: new Randomizer());
806+
807+ $backoff->delayFor(attempt: 1); # 100 ms, give or take up to 30 percent
808+ $backoff->delayFor(attempt: 2); # 200 ms, give or take up to 30 percent
809+ $backoff->delayFor(attempt: 3); # 400 ms, give or take up to 30 percent
810+ ```
811+
812+ #### Setting outbound headers
813+
814+ ` HeaderSettingClient ` is a PSR-18 decorator that sets headers on every outbound request, resolving each value at
815+ send time. Values that change between requests (a correlation identifier, a rotating token) are always current. A
816+ resolved value replaces any header of the same name already on the request, and a value resolving to an empty
817+ string leaves the request untouched for that name.
818+
819+ ``` php
820+ <?php
821+
822+ declare(strict_types=1);
823+
824+ use GuzzleHttp\Client;
825+ use TinyBlocks\Http\Client\HeaderSettingClient;
826+
827+ $client = HeaderSettingClient::with(client: new Client(), headerValues: [
828+ 'Correlation-Id' => static fn(): string => $correlationId->toString()
829+ ]);
830+ ```
831+
832+ It composes with the other client decorators. Wrapping it with ` RetryingClient ` re-resolves the headers on every
833+ attempt.
834+
687835#### Configuring timeouts
688836
689837PSR-18 does not standardize timeouts. Configure them on the underlying client before injection.
@@ -769,7 +917,8 @@ $received = $transport->receivedRequests();
769917#### Extending with custom transports
770918
771919Implement ` Transport ` to add retry, logging, circuit breaker, or any other cross-cutting concern. The decorator wraps
772- any inner ` Transport ` .
920+ any inner ` Transport ` . For retries at the PSR-18 client level, the library ships ` RetryingClient ` . See
921+ [ Retrying failed requests] ( #retrying-failed-requests ) .
773922
774923``` php
775924<?php
0 commit comments