From 9e41dfb96f2acecfe1dc14e3966b73510539ca2a Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 13 Jul 2026 09:25:24 -0300 Subject: [PATCH] feat: Add PSR-18 client resilience and header-setting decorators. RetryingClient retries network failures and 5xx responses with a configurable Backoff (FixedDelay or ExponentialBackoff with jitter), never retries 4xx, and reports every failed attempt to a RetryListener with its Elapsed duration and AttemptOutcome classification. Timeout statuses recognized by Code::isTimeout() (408 and 504) classify as TIMEOUT and stay retryable. Assembly goes through the fluent builder returned by RetryingClient::create(), which raises ClientNotConfigured without a client and resolves opinionated defaults otherwise. HeaderSettingClient sets headers on every outbound request, resolving each value at send time and omitting headers whose value resolves to an empty string. Duration measuring reuses tiny-blocks/time, now a direct requirement. --- README.md | 151 +++++++- composer.json | 3 +- src/Client/HeaderSettingClient.php | 59 +++ src/Client/Resilience/AttemptOutcome.php | 70 ++++ src/Client/Resilience/Backoff.php | 23 ++ src/Client/Resilience/ExponentialBackoff.php | 43 +++ src/Client/Resilience/FixedDelay.php | 31 ++ src/Client/Resilience/RetryListener.php | 32 ++ src/Client/Resilience/RetryingClient.php | 98 +++++ .../Resilience/RetryingClientBuilder.php | 131 +++++++ src/Client/Resilience/Sleeper.php | 18 + src/Exceptions/ClientNotConfigured.php | 30 ++ .../Resilience/IgnoringRetryListener.php | 21 ++ .../Client/Resilience/SystemSleeper.php | 15 + tests/Unit/Client/HeaderSettingClientTest.php | 139 +++++++ .../Client/Resilience/AttemptOutcomeTest.php | 122 +++++++ .../Resilience/ExponentialBackoffTest.php | 55 +++ .../Unit/Client/Resilience/FixedDelayTest.php | 34 ++ .../Client/Resilience/RetryingClientTest.php | 343 ++++++++++++++++++ tests/Unit/FixedBytesEngine.php | 19 + tests/Unit/RecordingListener.php | 34 ++ tests/Unit/RecordingSleeper.php | 22 ++ tests/Unit/ScriptedClient.php | 48 +++ tests/Unit/SteppingClock.php | 25 ++ 24 files changed, 1564 insertions(+), 2 deletions(-) create mode 100644 src/Client/HeaderSettingClient.php create mode 100644 src/Client/Resilience/AttemptOutcome.php create mode 100644 src/Client/Resilience/Backoff.php create mode 100644 src/Client/Resilience/ExponentialBackoff.php create mode 100644 src/Client/Resilience/FixedDelay.php create mode 100644 src/Client/Resilience/RetryListener.php create mode 100644 src/Client/Resilience/RetryingClient.php create mode 100644 src/Client/Resilience/RetryingClientBuilder.php create mode 100644 src/Client/Resilience/Sleeper.php create mode 100644 src/Exceptions/ClientNotConfigured.php create mode 100644 src/Internal/Client/Resilience/IgnoringRetryListener.php create mode 100644 src/Internal/Client/Resilience/SystemSleeper.php create mode 100644 tests/Unit/Client/HeaderSettingClientTest.php create mode 100644 tests/Unit/Client/Resilience/AttemptOutcomeTest.php create mode 100644 tests/Unit/Client/Resilience/ExponentialBackoffTest.php create mode 100644 tests/Unit/Client/Resilience/FixedDelayTest.php create mode 100644 tests/Unit/Client/Resilience/RetryingClientTest.php create mode 100644 tests/Unit/FixedBytesEngine.php create mode 100644 tests/Unit/RecordingListener.php create mode 100644 tests/Unit/RecordingSleeper.php create mode 100644 tests/Unit/ScriptedClient.php create mode 100644 tests/Unit/SteppingClock.php diff --git a/README.md b/README.md index fb8fc04..9661c5c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ - [Default headers](#default-headers) - [Setting the User-Agent](#setting-the-user-agent) - [Error handling](#error-handling) + - [Retrying failed requests](#retrying-failed-requests) + - [Backoff policies](#backoff-policies) + - [Setting outbound headers](#setting-outbound-headers) - [Configuring timeouts](#configuring-timeouts) - [Testing with InMemoryTransport](#testing-with-inmemorytransport) - [Extending with custom transports](#extending-with-custom-transports) @@ -35,6 +38,9 @@ The library covers both sides of an HTTP exchange: outgoing `ResponseInterface` instances with cookies, cache-control, and status codes. - **Client side** (`TinyBlocks\Http\Client`) - composes outbound requests, sends them through a `Transport` port backed by any PSR-18 client, and exposes responses with typed body and header access. +- **Client resilience** (`TinyBlocks\Http\Client\Resilience`) - decorates any PSR-18 client with retries, backoff + policies, and notification of failed attempts, measuring each attempt with + [tiny-blocks/time](https://github.com/tiny-blocks/time). Shared primitives at `TinyBlocks\Http\`: `Method`, `Code`, `Headers`, `Headerable`, `ContentType`, `MimeType`, `Charset`, `Cookie`, `SameSite`, `CacheControl`, `ResponseCacheDirectives`, `Link`, `LinkRelation`, `UserAgent`. @@ -681,9 +687,151 @@ try { | `MalformedPath` | Path attempts to escape the base URL (scheme, protocol-relative, control characters). | | `NoMoreResponses` | `InMemoryTransport` exhausted (programmer error). | | `HttpConfigurationInvalid` | Builder called without required dependencies. | +| `ClientNotConfigured` | `RetryingClientBuilder::build()` called without a PSR-18 client. | | `SynthesizedResponseHasNoRaw` | `Response::raw()` called on a response created via `Response::with(...)`. | | `HttpResponseUnsuccessful` | `Response::orFail()` called on a non-2xx response. | +#### Retrying failed requests + +`RetryingClient` is a PSR-18 decorator that retries transient failures. A network failure or a server error (HTTP 5xx) +is retried until the attempt ceiling is reached, sleeping the configured [backoff](#backoff-policies) delay between +attempts. A client error (HTTP 4xx) is never retried: the response is returned as is. Any other failure raised by the +decorated client propagates immediately. When the attempts are exhausted, the last response is returned or the last +exception is rethrown. The `maxAttempts` ceiling counts the first attempt, so `maxAttempts: 2` means one retry. + +Every failed attempt, the final one included, is reported to an optional `RetryListener` with the elapsed interval of +the attempt (an `Elapsed` from [tiny-blocks/time](https://github.com/tiny-blocks/time)), its `AttemptOutcome` +classification, the request, and the attempt number. Successful attempts are never reported. + +| `AttemptOutcome` | Trigger | Retried | +|------------------------------------|---------------------------------------------------|---------| +| `AttemptOutcome::TIMEOUT` | Network failure whose message mentions a timeout, or an HTTP 408 or 504 response. | Yes | +| `AttemptOutcome::CONNECTION_RESET` | Any other network failure. | Yes | +| `AttemptOutcome::SERVER_ERROR` | Any other HTTP 5xx response, non-RFC codes included. | Yes | +| `AttemptOutcome::CLIENT_ERROR` | Any other HTTP 4xx response. | No | + +Assemble the decorator with the fluent builder returned by `RetryingClient::create()`. Only the PSR-18 client is +required, and `build()` raises `ClientNotConfigured` without one. Every other collaborator falls back to an +opinionated default: an `ExponentialBackoff` with random jitter, an attempt ceiling of three, the system monotonic +clock and sleeper, and a listener that ignores failures. + +```php +logger->warning('http_attempt_failed', [ + 'target' => (string)$request->getUri(), + 'outcome' => $outcome->value, + 'elapsed_ms' => $elapsed->toMilliseconds(), + 'attempt_number' => $attemptNumber + ]); + } +} + +# One retry after a fixed 500 ms delay. +$client = RetryingClient::create() + ->withClient(client: new Client(config: ['timeout' => 10, 'connect_timeout' => 5])) + ->withBackoff(backoff: FixedDelay::ofMicroseconds(microseconds: 500000)) + ->withListener(listener: new LoggingRetryListener(logger: $logger)) + ->withMaxAttempts(maxAttempts: 2) + ->build(); + +$response = $client->sendRequest($request); +``` + +The listener is optional. When omitted, failed attempts are silently ignored. Because `RetryingClient` is itself a +PSR-18 client, it plugs into anything that accepts one, including the library's own transport: + +```php +delayFor(attempt: 1); # 100 ms, give or take up to 30 percent +$backoff->delayFor(attempt: 2); # 200 ms, give or take up to 30 percent +$backoff->delayFor(attempt: 3); # 400 ms, give or take up to 30 percent +``` + +#### Setting outbound headers + +`HeaderSettingClient` is a PSR-18 decorator that sets headers on every outbound request, resolving each value at +send time. Values that change between requests (a correlation identifier, a rotating token) are always current. A +resolved value replaces any header of the same name already on the request, and a value resolving to an empty +string leaves the request untouched for that name. + +```php + static fn(): string => $correlationId->toString() +]); +``` + +It composes with the other client decorators. Wrapping it with `RetryingClient` re-resolves the headers on every +attempt. + #### Configuring timeouts PSR-18 does not standardize timeouts. Configure them on the underlying client before injection. @@ -769,7 +917,8 @@ $received = $transport->receivedRequests(); #### Extending with custom transports Implement `Transport` to add retry, logging, circuit breaker, or any other cross-cutting concern. The decorator wraps -any inner `Transport`. +any inner `Transport`. For retries at the PSR-18 client level, the library ships `RetryingClient`. See +[Retrying failed requests](#retrying-failed-requests). ```php Each header value is a deferred resolution invoked per request, so values that change between + * requests (a correlation identifier, a rotating token) are always current. A resolved value + * replaces any header of the same name already present on the outbound request. A header whose + * value resolves to an empty string is omitted, and the request keeps whatever it already + * carried under that name.

+ */ +final readonly class HeaderSettingClient implements ClientInterface +{ + /** + * @param ClientInterface $client The underlying client to delegate sends to. + * @param array $headerValues The header names, each mapped to the + * resolution producing its value. + */ + private function __construct(private ClientInterface $client, private array $headerValues) + { + } + + /** + * Creates a HeaderSettingClient from a PSR-18 client and the headers to set on each request. + * + * @param ClientInterface $client The underlying client to delegate sends to. + * @param array $headerValues The header names, each mapped to the + * resolution producing its value. + * @return HeaderSettingClient The created instance. + */ + public static function with(ClientInterface $client, array $headerValues): HeaderSettingClient + { + return new HeaderSettingClient(client: $client, headerValues: $headerValues); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + foreach ($this->headerValues as $name => $resolveValue) { + $value = $resolveValue(); + + if ($value === '') { + continue; + } + + $request = $request->withHeader($name, $value); + } + + return $this->client->sendRequest($request); + } +} diff --git a/src/Client/Resilience/AttemptOutcome.php b/src/Client/Resilience/AttemptOutcome.php new file mode 100644 index 0000000..7b9b1e4 --- /dev/null +++ b/src/Client/Resilience/AttemptOutcome.php @@ -0,0 +1,70 @@ +A failure whose message mentions a timeout classifies as {@see AttemptOutcome::TIMEOUT}. + * Every other transport failure classifies as {@see AttemptOutcome::CONNECTION_RESET}.

+ * + * @param Throwable $throwable The transport failure to classify. + * @return AttemptOutcome The classification of the failure. + */ + public static function fromThrowable(Throwable $throwable): AttemptOutcome + { + $message = strtolower($throwable->getMessage()); + $isTimeout = str_contains($message, 'timed out') || str_contains($message, 'timeout'); + + return $isTimeout ? AttemptOutcome::TIMEOUT : AttemptOutcome::CONNECTION_RESET; + } + + /** + * Creates an AttemptOutcome from an HTTP status code, or null when the status is not a failure. + * + *

The timeout statuses recognized by {@see Code::isTimeout()} (408 Request Timeout and 504 + * Gateway Timeout) classify as {@see AttemptOutcome::TIMEOUT}, so they stay retryable and the + * failure log carries the timeout vocabulary. The remaining floors are open-ended on purpose: + * non-RFC codes above the {@see Code} ranges (a 599 from a proxy, for example) still classify + * as failures and stay retryable.

+ * + * @param int $statusCode The HTTP status code to classify. + * @return AttemptOutcome|null The classification of the status, or null below the client error floor. + */ + public static function fromStatusCode(int $statusCode): ?AttemptOutcome + { + $code = Code::tryFrom($statusCode); + + return match (true) { + !is_null($code) && $code->isTimeout() => AttemptOutcome::TIMEOUT, + $statusCode >= Code::INTERNAL_SERVER_ERROR->value => AttemptOutcome::SERVER_ERROR, + $statusCode >= Code::BAD_REQUEST->value => AttemptOutcome::CLIENT_ERROR, + default => null + }; + } + + /** + * Tells whether a retry is worthwhile after this outcome. + * + * @return bool True for every outcome except {@see AttemptOutcome::CLIENT_ERROR}. + */ + public function isRetryable(): bool + { + return $this !== AttemptOutcome::CLIENT_ERROR; + } +} diff --git a/src/Client/Resilience/Backoff.php b/src/Client/Resilience/Backoff.php new file mode 100644 index 0000000..3e0ed71 --- /dev/null +++ b/src/Client/Resilience/Backoff.php @@ -0,0 +1,23 @@ +Implementations compute how long a {@see RetryingClient} waits before the next attempt. + * Built-in: {@see FixedDelay} (constant delay) and {@see ExponentialBackoff} (doubling delay + * with random jitter).

+ */ +interface Backoff +{ + /** + * Returns the delay applied before the next attempt, in microseconds. + * + * @param int $attempt The number of the attempt that has just failed, starting at one. + * @return int The delay applied before the next attempt, in microseconds. + */ + public function delayFor(int $attempt): int; +} diff --git a/src/Client/Resilience/ExponentialBackoff.php b/src/Client/Resilience/ExponentialBackoff.php new file mode 100644 index 0000000..2af992b --- /dev/null +++ b/src/Client/Resilience/ExponentialBackoff.php @@ -0,0 +1,43 @@ +The delay for attempt N is 100ms * 2^(N - 1), adjusted by a uniformly random + * jitter of up to 30 percent of that value in either direction. The jitter keeps concurrent + * clients from retrying in lockstep against a recovering dependency.

+ */ +final readonly class ExponentialBackoff implements Backoff +{ + private const int JITTER_PERCENT = 30; + private const int BASE_MICROSECONDS = 100000; + + private function __construct(private Randomizer $randomizer) + { + } + + /** + * Creates an ExponentialBackoff from the randomizer that draws the jitter. + * + * @param Randomizer $randomizer The randomizer used to draw the jitter of each delay. + * @return ExponentialBackoff The created instance. + */ + public static function with(Randomizer $randomizer): ExponentialBackoff + { + return new ExponentialBackoff(randomizer: $randomizer); + } + + public function delayFor(int $attempt): int + { + $exponential = (ExponentialBackoff::BASE_MICROSECONDS * (2 ** ($attempt - 1))); + $spread = intdiv(($exponential * ExponentialBackoff::JITTER_PERCENT), 100); + + return ($exponential + $this->randomizer->getInt(-$spread, $spread)); + } +} diff --git a/src/Client/Resilience/FixedDelay.php b/src/Client/Resilience/FixedDelay.php new file mode 100644 index 0000000..8c72293 --- /dev/null +++ b/src/Client/Resilience/FixedDelay.php @@ -0,0 +1,31 @@ +microseconds; + } +} diff --git a/src/Client/Resilience/RetryListener.php b/src/Client/Resilience/RetryListener.php new file mode 100644 index 0000000..a6e714b --- /dev/null +++ b/src/Client/Resilience/RetryListener.php @@ -0,0 +1,32 @@ +Notified once for every failed attempt, including the final one when the attempts are + * exhausted. Successful attempts are never reported.

+ */ +interface RetryListener +{ + /** + * Receives the notification of a failed attempt. + * + * @param Elapsed $elapsed The elapsed interval measured for the failed attempt. + * @param AttemptOutcome $outcome The classification of the failure. + * @param RequestInterface $request The request whose attempt failed. + * @param int $attemptNumber The number of the failed attempt, starting at one. + */ + public function attemptFailed( + Elapsed $elapsed, + AttemptOutcome $outcome, + RequestInterface $request, + int $attemptNumber + ): void; +} diff --git a/src/Client/Resilience/RetryingClient.php b/src/Client/Resilience/RetryingClient.php new file mode 100644 index 0000000..76eac54 --- /dev/null +++ b/src/Client/Resilience/RetryingClient.php @@ -0,0 +1,98 @@ +A network failure or a server error (HTTP 5xx) is retried until the attempt ceiling is + * reached, sleeping the backoff delay between attempts. A client error (HTTP 4xx) is never + * retried, and the response is returned as is. Any other failure raised by the decorated + * client propagates immediately. When the attempts are exhausted, the last response is + * returned or the last failure is rethrown.

+ * + *

Every failed attempt, the final one included, is reported to the configured + * {@see RetryListener} with its elapsed interval, {@see AttemptOutcome}, request, and + * attempt number.

+ */ +final readonly class RetryingClient implements ClientInterface +{ + public function __construct( + private MonotonicClock $clock, + private ClientInterface $client, + private Backoff $backoff, + private Sleeper $sleeper, + private RetryListener $listener, + private int $maxAttempts + ) { + } + + /** + * Returns a fluent builder used to assemble a RetryingClient. + * + *

A PSR-18 client must be supplied through the builder before calling build(), + * otherwise ClientNotConfigured is raised. Every other collaborator falls back to + * an opinionated default resolved by the builder.

+ * + * @return RetryingClientBuilder A new, empty builder. + */ + public static function create(): RetryingClientBuilder + { + return new RetryingClientBuilder(); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + for ($attemptNumber = 1; true; $attemptNumber++) { + $stopwatch = Stopwatch::start(clock: $this->clock); + + try { + $response = $this->client->sendRequest($request); + } catch (NetworkExceptionInterface $exception) { + $this->listener->attemptFailed( + elapsed: $stopwatch->elapsed(), + outcome: AttemptOutcome::fromThrowable(throwable: $exception), + request: $request, + attemptNumber: $attemptNumber + ); + + if ($attemptNumber >= $this->maxAttempts) { + throw $exception; + } + + $microseconds = $this->backoff->delayFor(attempt: $attemptNumber); + $this->sleeper->sleep(microseconds: $microseconds); + continue; + } + + $outcome = AttemptOutcome::fromStatusCode(statusCode: $response->getStatusCode()); + + if (is_null($outcome)) { + return $response; + } + + $this->listener->attemptFailed( + elapsed: $stopwatch->elapsed(), + outcome: $outcome, + request: $request, + attemptNumber: $attemptNumber + ); + + if (!$outcome->isRetryable() || $attemptNumber >= $this->maxAttempts) { + return $response; + } + + $microseconds = $this->backoff->delayFor(attempt: $attemptNumber); + $this->sleeper->sleep(microseconds: $microseconds); + } + } +} diff --git a/src/Client/Resilience/RetryingClientBuilder.php b/src/Client/Resilience/RetryingClientBuilder.php new file mode 100644 index 0000000..0ed6e48 --- /dev/null +++ b/src/Client/Resilience/RetryingClientBuilder.php @@ -0,0 +1,131 @@ +Every collaborator except the client falls back to an opinionated default resolved at + * build time: an {@see ExponentialBackoff} with random jitter, the system monotonic clock, + * the system sleeper, and a listener that ignores failures. The attempt ceiling defaults + * to three.

+ */ +final class RetryingClientBuilder +{ + private ?MonotonicClock $clock = null; + private ?ClientInterface $client = null; + private ?Backoff $backoff = null; + private ?Sleeper $sleeper = null; + private ?RetryListener $listener = null; + private int $maxAttempts = 3; + + /** + * Builds a RetryingClient from the configured client and collaborators. + * + *

Collaborators left unconfigured fall back to the opinionated defaults. The attempt + * ceiling counts the first attempt, so values below two mean a single attempt.

+ * + * @return RetryingClient The configured client instance. + * @throws ClientNotConfigured If no PSR-18 client was configured. + */ + public function build(): RetryingClient + { + if (is_null($this->client)) { + throw ClientNotConfigured::create(); + } + + return new RetryingClient( + clock: $this->clock ?? new SystemMonotonicClock(), + client: $this->client, + backoff: $this->backoff ?? ExponentialBackoff::with(randomizer: new Randomizer()), + sleeper: $this->sleeper ?? new SystemSleeper(), + listener: $this->listener ?? new IgnoringRetryListener(), + maxAttempts: $this->maxAttempts + ); + } + + /** + * Sets the delay policy applied between attempts and returns the builder. + * + * @param Backoff $backoff The delay policy applied between attempts. + * @return RetryingClientBuilder The builder instance for fluent configuration. + */ + public function withBackoff(Backoff $backoff): RetryingClientBuilder + { + $this->backoff = $backoff; + return $this; + } + + /** + * Sets the PSR-18 client that performs each attempt and returns the builder. + * + * @param ClientInterface $client The PSR-18 client that performs each attempt. + * @return RetryingClientBuilder The builder instance for fluent configuration. + */ + public function withClient(ClientInterface $client): RetryingClientBuilder + { + $this->client = $client; + return $this; + } + + /** + * Sets the monotonic clock measuring each attempt and returns the builder. + * + * @param MonotonicClock $clock The monotonic clock measuring each attempt. + * @return RetryingClientBuilder The builder instance for fluent configuration. + */ + public function withClock(MonotonicClock $clock): RetryingClientBuilder + { + $this->clock = $clock; + return $this; + } + + /** + * Sets the listener notified of every failed attempt and returns the builder. + * + * @param RetryListener $listener The listener notified of every failed attempt. + * @return RetryingClientBuilder The builder instance for fluent configuration. + */ + public function withListener(RetryListener $listener): RetryingClientBuilder + { + $this->listener = $listener; + return $this; + } + + /** + * Sets the attempt ceiling and returns the builder. + * + *

The ceiling counts the first attempt, so a value of two means one retry. Values + * below two mean a single attempt, and no validation is applied.

+ * + * @param int $maxAttempts The attempt ceiling, counting the first attempt. + * @return RetryingClientBuilder The builder instance for fluent configuration. + */ + public function withMaxAttempts(int $maxAttempts): RetryingClientBuilder + { + $this->maxAttempts = $maxAttempts; + return $this; + } + + /** + * Sets the suspension applied between attempts and returns the builder. + * + * @param Sleeper $sleeper The suspension applied between attempts. + * @return RetryingClientBuilder The builder instance for fluent configuration. + */ + public function withSleeper(Sleeper $sleeper): RetryingClientBuilder + { + $this->sleeper = $sleeper; + return $this; + } +} diff --git a/src/Client/Resilience/Sleeper.php b/src/Client/Resilience/Sleeper.php new file mode 100644 index 0000000..8e41189 --- /dev/null +++ b/src/Client/Resilience/Sleeper.php @@ -0,0 +1,18 @@ +RetryingClientBuilder::build() is called without a PSR-18 client configured. + */ +final class ClientNotConfigured extends LogicException implements HttpException +{ + private const string REASON = 'A client must be provided to build the RetryingClient.'; + + private function __construct() + { + parent::__construct(message: ClientNotConfigured::REASON); + } + + /** + * Creates a ClientNotConfigured signaling that the PSR-18 client is missing. + * + * @return ClientNotConfigured The composed exception describing the missing-client state. + */ + public static function create(): ClientNotConfigured + { + return new ClientNotConfigured(); + } +} diff --git a/src/Internal/Client/Resilience/IgnoringRetryListener.php b/src/Internal/Client/Resilience/IgnoringRetryListener.php new file mode 100644 index 0000000..9a8d82b --- /dev/null +++ b/src/Internal/Client/Resilience/IgnoringRetryListener.php @@ -0,0 +1,21 @@ +answers(new Response()); + $client = HeaderSettingClient::with( + client: $network, + headerValues: ['Correlation-Id' => static fn(): string => 'corr-123'] + ); + + /** @When a request is sent */ + $client->sendRequest(new Request('GET', 'https://service.test/ping')); + + /** @Then the outbound request carries the resolved header */ + $sentRequest = $network->requestAt(index: 0); + self::assertSame('corr-123', $sentRequest->getHeaderLine('Correlation-Id')); + } + + public function testResolvesTheValueOnEverySend(): void + { + /** @Given a value that changes between sends */ + $values = ['first-id', 'second-id']; + $network = new ScriptedClient(); + $network->answers(new Response(), new Response()); + $client = HeaderSettingClient::with( + client: $network, + headerValues: ['Correlation-Id' => static function () use (&$values): string { + return array_shift($values); + }] + ); + + /** @When two requests are sent */ + $client->sendRequest(new Request('GET', 'https://service.test/first')); + $client->sendRequest(new Request('GET', 'https://service.test/second')); + + /** @Then each outbound request carries the value current at its send */ + self::assertSame('first-id', $network->requestAt(index: 0)->getHeaderLine('Correlation-Id')); + self::assertSame('second-id', $network->requestAt(index: 1)->getHeaderLine('Correlation-Id')); + } + + public function testOmitsTheHeaderWhenTheValueResolvesEmpty(): void + { + /** @Given a header value that resolves to an empty string */ + $network = new ScriptedClient(); + $network->answers(new Response()); + $client = HeaderSettingClient::with( + client: $network, + headerValues: ['Correlation-Id' => static fn(): string => ''] + ); + + /** @When a request already carrying the header is sent */ + $client->sendRequest( + new Request('GET', 'https://service.test/ping', ['Correlation-Id' => 'preexisting-id']) + ); + + /** @Then the request keeps what it already carried under that name */ + self::assertSame('preexisting-id', $network->requestAt(index: 0)->getHeaderLine('Correlation-Id')); + } + + public function testReplacesAHeaderAlreadyPresentOnTheRequest(): void + { + /** @Given a header value resolving while the request already carries the same header */ + $network = new ScriptedClient(); + $network->answers(new Response()); + $client = HeaderSettingClient::with( + client: $network, + headerValues: ['Correlation-Id' => static fn(): string => 'resolved-id'] + ); + + /** @When the request is sent */ + $client->sendRequest( + new Request('GET', 'https://service.test/ping', ['Correlation-Id' => 'stale-id']) + ); + + /** @Then the resolved value replaces the preexisting one */ + self::assertSame('resolved-id', $network->requestAt(index: 0)->getHeaderLine('Correlation-Id')); + } + + public function testKeepsSettingTheRemainingHeadersAfterAnEmptyValue(): void + { + /** @Given a first header resolving empty followed by a second one resolving a value */ + $network = new ScriptedClient(); + $network->answers(new Response()); + $client = HeaderSettingClient::with( + client: $network, + headerValues: [ + 'Correlation-Id' => static fn(): string => '', + 'X-Service-Token' => static fn(): string => 'token-xyz' + ] + ); + + /** @When a request is sent */ + $client->sendRequest(new Request('GET', 'https://service.test/ping')); + + /** @Then the empty header is omitted and the following one is still set */ + $sentRequest = $network->requestAt(index: 0); + self::assertFalse($sentRequest->hasHeader('Correlation-Id')); + self::assertSame('token-xyz', $sentRequest->getHeaderLine('X-Service-Token')); + } + + public function testSetsSeveralHeadersPreservingUnrelatedOnes(): void + { + /** @Given a client setting two headers on a request carrying an unrelated one */ + $network = new ScriptedClient(); + $network->answers(new Response()); + $client = HeaderSettingClient::with( + client: $network, + headerValues: [ + 'X-Service-Token' => static fn(): string => 'token-abc', + 'Correlation-Id' => static fn(): string => 'corr-999' + ] + ); + + /** @When a request with an unrelated header is sent */ + $client->sendRequest( + new Request('GET', 'https://service.test/ping', ['Accept' => 'application/json']) + ); + + /** @Then both resolved headers are set and the unrelated one is preserved */ + $sentRequest = $network->requestAt(index: 0); + self::assertSame('token-abc', $sentRequest->getHeaderLine('X-Service-Token')); + self::assertSame('corr-999', $sentRequest->getHeaderLine('Correlation-Id')); + self::assertSame('application/json', $sentRequest->getHeaderLine('Accept')); + } +} diff --git a/tests/Unit/Client/Resilience/AttemptOutcomeTest.php b/tests/Unit/Client/Resilience/AttemptOutcomeTest.php new file mode 100644 index 0000000..5b251ac --- /dev/null +++ b/tests/Unit/Client/Resilience/AttemptOutcomeTest.php @@ -0,0 +1,122 @@ +isRetryable(); + + /** @Then only the client error is not retryable */ + self::assertSame($expected, $isRetryable); + } + + public static function retryableScenarios(): array + { + return [ + 'Timeout is retryable' => ['outcome' => AttemptOutcome::TIMEOUT, 'expected' => true], + 'Server error is retryable' => ['outcome' => AttemptOutcome::SERVER_ERROR, 'expected' => true], + 'Connection reset is retryable' => ['outcome' => AttemptOutcome::CONNECTION_RESET, 'expected' => true], + 'Client error is never retried' => ['outcome' => AttemptOutcome::CLIENT_ERROR, 'expected' => false] + ]; + } + + public static function throwableScenarios(): array + { + return [ + 'Timed out message classifies a timeout' => [ + 'message' => 'cURL error 28: Operation timed out after 10001 milliseconds', + 'expected' => AttemptOutcome::TIMEOUT + ], + 'Timeout message classifies a timeout' => [ + 'message' => 'Connection timeout reached', + 'expected' => AttemptOutcome::TIMEOUT + ], + 'Uppercase timeout message classifies' => [ + 'message' => 'OPERATION TIMED OUT', + 'expected' => AttemptOutcome::TIMEOUT + ], + 'Any other message classifies a broken socket' => [ + 'message' => 'cURL error 56: Connection reset by peer', + 'expected' => AttemptOutcome::CONNECTION_RESET + ] + ]; + } + + public static function statusCodeScenarios(): array + { + return [ + 'Success is not a failure' => ['expected' => null, 'statusCode' => 200], + 'Redirect is not a failure' => ['expected' => null, 'statusCode' => 399], + 'Client error lower bound classifies' => [ + 'expected' => AttemptOutcome::CLIENT_ERROR, + 'statusCode' => 400 + ], + 'Client error upper bound classifies' => [ + 'expected' => AttemptOutcome::CLIENT_ERROR, + 'statusCode' => 499 + ], + 'Server error lower bound classifies' => [ + 'expected' => AttemptOutcome::SERVER_ERROR, + 'statusCode' => 500 + ], + 'Request timeout classifies a timeout' => [ + 'expected' => AttemptOutcome::TIMEOUT, + 'statusCode' => 408 + ], + 'Gateway timeout classifies a timeout' => [ + 'expected' => AttemptOutcome::TIMEOUT, + 'statusCode' => 504 + ], + 'Service unavailable is a server error' => [ + 'expected' => AttemptOutcome::SERVER_ERROR, + 'statusCode' => 503 + ], + 'Non-RFC proxy code is still a server error' => [ + 'expected' => AttemptOutcome::SERVER_ERROR, + 'statusCode' => 599 + ] + ]; + } +} diff --git a/tests/Unit/Client/Resilience/ExponentialBackoffTest.php b/tests/Unit/Client/Resilience/ExponentialBackoffTest.php new file mode 100644 index 0000000..9dce48c --- /dev/null +++ b/tests/Unit/Client/Resilience/ExponentialBackoffTest.php @@ -0,0 +1,55 @@ +delayFor(attempt: $attempt); + + /** @Then the delay is the exponential base for the attempt minus the full jitter band */ + self::assertSame($expectedDelay, $delay); + } + + public function testDelayForWhenARealRandomizerDrawsTheJitterThenTheDelayStaysWithinTheBounds(): void + { + /** @Given an exponential backoff backed by a real randomizer */ + $backoff = ExponentialBackoff::with(randomizer: new Randomizer()); + + /** @When the delay for the second attempt is computed */ + $delay = $backoff->delayFor(attempt: 2); + + /** @Then the delay stays within thirty percent of two hundred milliseconds */ + self::assertGreaterThanOrEqual(140000, $delay); + self::assertLessThanOrEqual(260000, $delay); + } + + public static function attemptScenarios(): array + { + return [ + 'Attempt one is 100ms minus 30 percent' => ['attempt' => 1, 'expectedDelay' => 70000], + 'Attempt two is 200ms minus 30 percent' => ['attempt' => 2, 'expectedDelay' => 140000], + 'Attempt three is 400ms minus 30 percent' => ['attempt' => 3, 'expectedDelay' => 280000] + ]; + } +} diff --git a/tests/Unit/Client/Resilience/FixedDelayTest.php b/tests/Unit/Client/Resilience/FixedDelayTest.php new file mode 100644 index 0000000..4e4a2b9 --- /dev/null +++ b/tests/Unit/Client/Resilience/FixedDelayTest.php @@ -0,0 +1,34 @@ +delayFor(attempt: $attempt); + + /** @Then the delay is the configured half a second */ + self::assertSame(500000, $delay); + } + + public static function attemptScenarios(): array + { + return [ + 'First attempt' => ['attempt' => 1], + 'Fifth attempt' => ['attempt' => 5], + 'Twelfth attempt' => ['attempt' => 12] + ]; + } +} diff --git a/tests/Unit/Client/Resilience/RetryingClientTest.php b/tests/Unit/Client/Resilience/RetryingClientTest.php new file mode 100644 index 0000000..6735f73 --- /dev/null +++ b/tests/Unit/Client/Resilience/RetryingClientTest.php @@ -0,0 +1,343 @@ +network = new ScriptedClient(); + $this->sleeper = new RecordingSleeper(); + $this->listener = new RecordingListener(); + $this->client = RetryingClient::create() + ->withClock(clock: new SteppingClock(startingAt: 1000000, stepInNanoseconds: 5000000)) + ->withClient(client: $this->network) + ->withBackoff(backoff: FixedDelay::ofMicroseconds(microseconds: 250000)) + ->withSleeper(sleeper: $this->sleeper) + ->withListener(listener: $this->listener) + ->withMaxAttempts(maxAttempts: 3) + ->build(); + } + + public function testBuildWhenNoClientIsConfiguredThenClientNotConfigured(): void + { + /** @Then an exception indicating the missing client should be raised */ + $this->expectException(ClientNotConfigured::class); + $this->expectExceptionMessage('A client must be provided to build the RetryingClient.'); + + /** @When building without a PSR-18 client configured */ + RetryingClient::create()->build(); + } + + #[DataProvider('retryScenarios')] + public function testSendRequestWhenStatusesGivenThenHonorsTheRetryPolicy( + array $statuses, + array $expectedSleeps, + array $expectedOutcomes, + int $expectedStatusCode + ): void { + /** @Given a network answering the scripted statuses */ + $this->network->answers(...array_map( + static fn(int $status): ResponseInterface => new Psr17Factory()->createResponse($status), + $statuses + )); + + /** @When a request is sent */ + $response = $this->client->sendRequest(new Psr17Factory()->createRequest('GET', self::URL)); + + /** @Then the response carries the expected status */ + self::assertSame($expectedStatusCode, $response->getStatusCode()); + + /** @And the slept delays match the retry policy */ + self::assertSame($expectedSleeps, $this->sleeper->sleeps()); + + /** @And the reported outcomes match the failed attempts */ + self::assertSame($expectedOutcomes, array_column($this->listener->failures(), 'outcome')); + } + + public function testSendRequestWhenConnectionFailsOnceThenRetriesAndSucceeds(): void + { + /** @Given a connection failure on the first attempt */ + $failure = new PsrNetworkException('connection reset by peer'); + + /** @And a network that fails once and then succeeds */ + $this->network->answers($failure, new Psr17Factory()->createResponse(200)); + + /** @When a request is sent */ + $response = $this->client->sendRequest(new Psr17Factory()->createRequest('GET', self::URL)); + + /** @Then the retry succeeds with the fixed delay slept once */ + self::assertSame(200, $response->getStatusCode()); + self::assertSame([250000], $this->sleeper->sleeps()); + + /** @And the failed attempt was reported as a connection reset */ + self::assertSame([AttemptOutcome::CONNECTION_RESET], array_column($this->listener->failures(), 'outcome')); + } + + public function testSendRequestWhenConstructedDirectlyThenHonorsTheRetryPolicy(): void + { + /** @Given a client assembled through the public constructor */ + $client = new RetryingClient( + clock: new SteppingClock(startingAt: 1000000, stepInNanoseconds: 5000000), + client: $this->network, + backoff: FixedDelay::ofMicroseconds(microseconds: 250000), + sleeper: $this->sleeper, + listener: $this->listener, + maxAttempts: 2 + ); + + /** @And a network answering a server error and then a success */ + $this->network->answers(new Psr17Factory()->createResponse(500), new Psr17Factory()->createResponse(200)); + + /** @When a request is sent */ + $response = $client->sendRequest(new Psr17Factory()->createRequest('GET', self::URL)); + + /** @Then the retry succeeds with the fixed delay slept once */ + self::assertSame(200, $response->getStatusCode()); + self::assertSame([250000], $this->sleeper->sleeps()); + + /** @And the failed attempt was reported as a server error */ + self::assertSame([AttemptOutcome::SERVER_ERROR], array_column($this->listener->failures(), 'outcome')); + } + + public function testSendRequestWhenNoSeamsGivenThenSleepsForRealBetweenAttempts(): void + { + /** @Given a client with the system seams and a thirty millisecond fixed delay */ + $client = RetryingClient::create() + ->withClient(client: $this->network) + ->withBackoff(backoff: FixedDelay::ofMicroseconds(microseconds: 30000)) + ->withMaxAttempts(maxAttempts: 2) + ->build(); + + /** @And a network answering a server error and then a success */ + $this->network->answers(new Psr17Factory()->createResponse(503), new Psr17Factory()->createResponse(200)); + + /** @And a captured monotonic instant */ + $startedAt = hrtime(true); + + /** @When a request is sent */ + $response = $client->sendRequest(new Psr17Factory()->createRequest('GET', self::URL)); + + /** @Then the retry succeeded */ + self::assertSame(200, $response->getStatusCode()); + + /** @And at least ten milliseconds of real suspension elapsed */ + self::assertGreaterThanOrEqual(10000, intdiv(hrtime(true) - $startedAt, 1000)); + } + + public function testSendRequestWhenNetworkKeepsFailingThenRethrowsTheLastFailure(): void + { + /** @Given a first connection failure */ + $first = new PsrNetworkException('connection reset by peer'); + + /** @And a second connection failure */ + $second = new PsrNetworkException('connection timed out'); + + /** @And a last connection failure */ + $last = new PsrNetworkException('connection timed out'); + + /** @And a network that keeps failing */ + $this->network->answers($first, $second, $last); + + try { + /** @When a request is sent */ + $this->client->sendRequest(new Psr17Factory()->createRequest('GET', self::URL)); + self::fail('The last connection failure was expected.'); + } catch (PsrNetworkException $failure) { + /** @Then the rethrown failure is the last one */ + self::assertSame($last, $failure); + } + + /** @And the fixed delay was slept between the attempts */ + self::assertSame([250000, 250000], $this->sleeper->sleeps()); + + /** @And every failed attempt was reported with its attempt number */ + self::assertSame([1, 2, 3], array_column($this->listener->failures(), 'attemptNumber')); + + /** @And the reported outcomes follow the failure classification */ + self::assertSame( + [AttemptOutcome::CONNECTION_RESET, AttemptOutcome::TIMEOUT, AttemptOutcome::TIMEOUT], + array_column($this->listener->failures(), 'outcome') + ); + } + + public function testSendRequestWhenExponentialBackoffGivenThenSleepsDoublingDelays(): void + { + /** @Given an exponential backoff with the jitter at its lower bound */ + $backoff = ExponentialBackoff::with( + randomizer: new Randomizer(engine: new FixedBytesEngine(bytes: self::ZERO_BYTES)) + ); + + /** @And a client retrying with that backoff */ + $client = RetryingClient::create() + ->withClient(client: $this->network) + ->withBackoff(backoff: $backoff) + ->withSleeper(sleeper: $this->sleeper) + ->withMaxAttempts(maxAttempts: 3) + ->build(); + + /** @And a network answering two server errors and then a success */ + $this->network->answers( + new Psr17Factory()->createResponse(500), + new Psr17Factory()->createResponse(500), + new Psr17Factory()->createResponse(200) + ); + + /** @When a request is sent */ + $response = $client->sendRequest(new Psr17Factory()->createRequest('GET', self::URL)); + + /** @Then the delays doubled between the attempts */ + self::assertSame(200, $response->getStatusCode()); + self::assertSame([70000, 140000], $this->sleeper->sleeps()); + } + + public function testSendRequestWhenSingleRetryPolicyGivenThenSleepsHalfASecondOnce(): void + { + /** @Given a client allowing a single retry after half a second */ + $client = RetryingClient::create() + ->withClient(client: $this->network) + ->withBackoff(backoff: FixedDelay::ofMicroseconds(microseconds: 500000)) + ->withSleeper(sleeper: $this->sleeper) + ->withMaxAttempts(maxAttempts: 2) + ->build(); + + /** @And a network answering a server error and then a success */ + $this->network->answers(new Psr17Factory()->createResponse(503), new Psr17Factory()->createResponse(200)); + + /** @When a request is sent */ + $response = $client->sendRequest(new Psr17Factory()->createRequest('GET', self::URL)); + + /** @Then the retry succeeded after a single sleep of half a second */ + self::assertSame(200, $response->getStatusCode()); + self::assertSame([500000], $this->sleeper->sleeps()); + } + + public function testSendRequestWhenAttemptFailsThenListenerReceivesTheFailureDetails(): void + { + /** @Given a request bound to the network */ + $request = new Psr17Factory()->createRequest('GET', self::URL); + + /** @And a network answering a server error and then a success */ + $this->network->answers(new Psr17Factory()->createResponse(503), new Psr17Factory()->createResponse(200)); + + /** @When the request is sent */ + $this->client->sendRequest($request); + + /** @Then the listener received the outcome, the request, the attempt number, and the elapsed interval */ + self::assertSame( + [ + [ + 'outcome' => AttemptOutcome::SERVER_ERROR, + 'request' => $request, + 'attemptNumber' => 1, + 'elapsedInMilliseconds' => 5.0 + ] + ], + $this->listener->failures() + ); + } + + public function testSendRequestWhenOnlyTheClientIsGivenThenDefaultsMakeThreeAttempts(): void + { + /** @Given a network answering three server errors */ + $this->network->answers( + new Psr17Factory()->createResponse(500), + new Psr17Factory()->createResponse(502), + new Psr17Factory()->createResponse(503) + ); + + /** @And a client keeping the default backoff and attempt ceiling */ + $client = RetryingClient::create() + ->withClient(client: $this->network) + ->withSleeper(sleeper: $this->sleeper) + ->build(); + + /** @When a request is sent */ + $response = $client->sendRequest(new Psr17Factory()->createRequest('GET', self::URL)); + + /** @Then the default ceiling exhausts after three attempts and the last response is returned */ + self::assertSame(503, $response->getStatusCode()); + self::assertCount(3, $this->network->requests()); + + /** @And the two default exponential delays stay within the jitter bounds */ + self::assertCount(2, $this->sleeper->sleeps()); + self::assertGreaterThanOrEqual(70000, $this->sleeper->sleeps()[0]); + self::assertLessThanOrEqual(130000, $this->sleeper->sleeps()[0]); + self::assertGreaterThanOrEqual(140000, $this->sleeper->sleeps()[1]); + self::assertLessThanOrEqual(260000, $this->sleeper->sleeps()[1]); + } + + public function testSendRequestWhenFailureIsNotNetworkRelatedThenPropagatesWithoutRetrying(): void + { + /** @Given a network answering a client failure and then a success */ + $this->network->answers(new PsrClientException('generic failure'), new Psr17Factory()->createResponse(200)); + + /** @Then the client failure propagates before any retry could succeed */ + $this->expectException(PsrClientException::class); + + /** @When a request is sent */ + $this->client->sendRequest(new Psr17Factory()->createRequest('GET', self::URL)); + } + + public static function retryScenarios(): array + { + return [ + 'Success returns without retrying' => [ + 'statuses' => [200], + 'expectedSleeps' => [], + 'expectedOutcomes' => [], + 'expectedStatusCode' => 200 + ], + 'Client error returns without retrying' => [ + 'statuses' => [404], + 'expectedSleeps' => [], + 'expectedOutcomes' => [AttemptOutcome::CLIENT_ERROR], + 'expectedStatusCode' => 404 + ], + 'Server error retries until a success' => [ + 'statuses' => [500, 200], + 'expectedSleeps' => [250000], + 'expectedOutcomes' => [AttemptOutcome::SERVER_ERROR], + 'expectedStatusCode' => 200 + ], + 'Server errors return the last response at the ceiling' => [ + 'statuses' => [500, 502, 503], + 'expectedSleeps' => [250000, 250000], + 'expectedOutcomes' => [ + AttemptOutcome::SERVER_ERROR, + AttemptOutcome::SERVER_ERROR, + AttemptOutcome::SERVER_ERROR + ], + 'expectedStatusCode' => 503 + ] + ]; + } +} diff --git a/tests/Unit/FixedBytesEngine.php b/tests/Unit/FixedBytesEngine.php new file mode 100644 index 0000000..611537d --- /dev/null +++ b/tests/Unit/FixedBytesEngine.php @@ -0,0 +1,19 @@ +bytes; + } +} diff --git a/tests/Unit/RecordingListener.php b/tests/Unit/RecordingListener.php new file mode 100644 index 0000000..e0494c6 --- /dev/null +++ b/tests/Unit/RecordingListener.php @@ -0,0 +1,34 @@ +failures; + } + + public function attemptFailed( + Elapsed $elapsed, + AttemptOutcome $outcome, + RequestInterface $request, + int $attemptNumber + ): void { + $this->failures[] = [ + 'outcome' => $outcome, + 'request' => $request, + 'attemptNumber' => $attemptNumber, + 'elapsedInMilliseconds' => $elapsed->toMilliseconds() + ]; + } +} diff --git a/tests/Unit/RecordingSleeper.php b/tests/Unit/RecordingSleeper.php new file mode 100644 index 0000000..2793350 --- /dev/null +++ b/tests/Unit/RecordingSleeper.php @@ -0,0 +1,22 @@ +sleeps[] = $microseconds; + } + + public function sleeps(): array + { + return $this->sleeps; + } +} diff --git a/tests/Unit/ScriptedClient.php b/tests/Unit/ScriptedClient.php new file mode 100644 index 0000000..4c74276 --- /dev/null +++ b/tests/Unit/ScriptedClient.php @@ -0,0 +1,48 @@ +outcomes = $outcomes; + } + + public function requests(): array + { + return $this->requests; + } + + public function requestAt(int $index): RequestInterface + { + return $this->requests[$index]; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + $outcome = array_shift($this->outcomes); + + if (is_null($outcome)) { + throw new LogicException(message: 'No scripted outcome left.'); + } + + if ($outcome instanceof Throwable) { + throw $outcome; + } + + return $outcome; + } +} diff --git a/tests/Unit/SteppingClock.php b/tests/Unit/SteppingClock.php new file mode 100644 index 0000000..09e8665 --- /dev/null +++ b/tests/Unit/SteppingClock.php @@ -0,0 +1,25 @@ +reading = $startingAt; + } + + public function nanoseconds(): int + { + $current = $this->reading; + $this->reading += $this->stepInNanoseconds; + + return $current; + } +}