From 9e41dfb96f2acecfe1dc14e3966b73510539ca2a Mon Sep 17 00:00:00 2001
From: Gustavo Freze
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 is100ms * 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; + } +}