Skip to content

Commit aea6d9a

Browse files
authored
Release/6.4.0 (#63)
1 parent 1f057e7 commit aea6d9a

24 files changed

Lines changed: 1564 additions & 2 deletions

README.md

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
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

3945
Shared 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

689837
PSR-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

771919
Implement `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

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"psr/http-client": "^1.0",
3232
"psr/http-factory": "^1.1",
3333
"psr/http-message": "^2.0",
34-
"tiny-blocks/mapper": "^3.1"
34+
"tiny-blocks/mapper": "^3.1",
35+
"tiny-blocks/time": "^2.3"
3536
},
3637
"require-dev": {
3738
"ergebnis/composer-normalize": "^2.52",

src/Client/HeaderSettingClient.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\Http\Client;
6+
7+
use Closure;
8+
use Psr\Http\Client\ClientInterface;
9+
use Psr\Http\Message\RequestInterface;
10+
use Psr\Http\Message\ResponseInterface;
11+
12+
/**
13+
* PSR-18 decorator that sets headers on every outbound request, resolving each value at send time.
14+
*
15+
* <p>Each header value is a deferred resolution invoked per request, so values that change between
16+
* requests (a correlation identifier, a rotating token) are always current. A resolved value
17+
* replaces any header of the same name already present on the outbound request. A header whose
18+
* value resolves to an empty string is omitted, and the request keeps whatever it already
19+
* carried under that name.</p>
20+
*/
21+
final readonly class HeaderSettingClient implements ClientInterface
22+
{
23+
/**
24+
* @param ClientInterface $client The underlying client to delegate sends to.
25+
* @param array<string, Closure(): string> $headerValues The header names, each mapped to the
26+
* resolution producing its value.
27+
*/
28+
private function __construct(private ClientInterface $client, private array $headerValues)
29+
{
30+
}
31+
32+
/**
33+
* Creates a HeaderSettingClient from a PSR-18 client and the headers to set on each request.
34+
*
35+
* @param ClientInterface $client The underlying client to delegate sends to.
36+
* @param array<string, Closure(): string> $headerValues The header names, each mapped to the
37+
* resolution producing its value.
38+
* @return HeaderSettingClient The created instance.
39+
*/
40+
public static function with(ClientInterface $client, array $headerValues): HeaderSettingClient
41+
{
42+
return new HeaderSettingClient(client: $client, headerValues: $headerValues);
43+
}
44+
45+
public function sendRequest(RequestInterface $request): ResponseInterface
46+
{
47+
foreach ($this->headerValues as $name => $resolveValue) {
48+
$value = $resolveValue();
49+
50+
if ($value === '') {
51+
continue;
52+
}
53+
54+
$request = $request->withHeader($name, $value);
55+
}
56+
57+
return $this->client->sendRequest($request);
58+
}
59+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\Http\Client\Resilience;
6+
7+
use Throwable;
8+
use TinyBlocks\Http\Code;
9+
10+
/**
11+
* Classification of a failed HTTP attempt, driving whether a retry is worthwhile.
12+
*/
13+
enum AttemptOutcome: string
14+
{
15+
case TIMEOUT = 'timeout';
16+
case CLIENT_ERROR = '4xx';
17+
case SERVER_ERROR = '5xx';
18+
case CONNECTION_RESET = 'connection_reset';
19+
20+
/**
21+
* Creates an AttemptOutcome from a transport failure.
22+
*
23+
* <p>A failure whose message mentions a timeout classifies as {@see AttemptOutcome::TIMEOUT}.
24+
* Every other transport failure classifies as {@see AttemptOutcome::CONNECTION_RESET}.</p>
25+
*
26+
* @param Throwable $throwable The transport failure to classify.
27+
* @return AttemptOutcome The classification of the failure.
28+
*/
29+
public static function fromThrowable(Throwable $throwable): AttemptOutcome
30+
{
31+
$message = strtolower($throwable->getMessage());
32+
$isTimeout = str_contains($message, 'timed out') || str_contains($message, 'timeout');
33+
34+
return $isTimeout ? AttemptOutcome::TIMEOUT : AttemptOutcome::CONNECTION_RESET;
35+
}
36+
37+
/**
38+
* Creates an AttemptOutcome from an HTTP status code, or null when the status is not a failure.
39+
*
40+
* <p>The timeout statuses recognized by {@see Code::isTimeout()} (408 Request Timeout and 504
41+
* Gateway Timeout) classify as {@see AttemptOutcome::TIMEOUT}, so they stay retryable and the
42+
* failure log carries the timeout vocabulary. The remaining floors are open-ended on purpose:
43+
* non-RFC codes above the {@see Code} ranges (a 599 from a proxy, for example) still classify
44+
* as failures and stay retryable.</p>
45+
*
46+
* @param int $statusCode The HTTP status code to classify.
47+
* @return AttemptOutcome|null The classification of the status, or null below the client error floor.
48+
*/
49+
public static function fromStatusCode(int $statusCode): ?AttemptOutcome
50+
{
51+
$code = Code::tryFrom($statusCode);
52+
53+
return match (true) {
54+
!is_null($code) && $code->isTimeout() => AttemptOutcome::TIMEOUT,
55+
$statusCode >= Code::INTERNAL_SERVER_ERROR->value => AttemptOutcome::SERVER_ERROR,
56+
$statusCode >= Code::BAD_REQUEST->value => AttemptOutcome::CLIENT_ERROR,
57+
default => null
58+
};
59+
}
60+
61+
/**
62+
* Tells whether a retry is worthwhile after this outcome.
63+
*
64+
* @return bool True for every outcome except {@see AttemptOutcome::CLIENT_ERROR}.
65+
*/
66+
public function isRetryable(): bool
67+
{
68+
return $this !== AttemptOutcome::CLIENT_ERROR;
69+
}
70+
}

src/Client/Resilience/Backoff.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\Http\Client\Resilience;
6+
7+
/**
8+
* Delay policy applied between retry attempts.
9+
*
10+
* <p>Implementations compute how long a {@see RetryingClient} waits before the next attempt.
11+
* Built-in: {@see FixedDelay} (constant delay) and {@see ExponentialBackoff} (doubling delay
12+
* with random jitter).</p>
13+
*/
14+
interface Backoff
15+
{
16+
/**
17+
* Returns the delay applied before the next attempt, in microseconds.
18+
*
19+
* @param int $attempt The number of the attempt that has just failed, starting at one.
20+
* @return int The delay applied before the next attempt, in microseconds.
21+
*/
22+
public function delayFor(int $attempt): int;
23+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\Http\Client\Resilience;
6+
7+
use Random\Randomizer;
8+
9+
/**
10+
* Backoff that doubles a base delay of 100 milliseconds on every attempt and spreads it with random jitter.
11+
*
12+
* <p>The delay for attempt N is <code>100ms * 2^(N - 1)</code>, adjusted by a uniformly random
13+
* jitter of up to 30 percent of that value in either direction. The jitter keeps concurrent
14+
* clients from retrying in lockstep against a recovering dependency.</p>
15+
*/
16+
final readonly class ExponentialBackoff implements Backoff
17+
{
18+
private const int JITTER_PERCENT = 30;
19+
private const int BASE_MICROSECONDS = 100000;
20+
21+
private function __construct(private Randomizer $randomizer)
22+
{
23+
}
24+
25+
/**
26+
* Creates an ExponentialBackoff from the randomizer that draws the jitter.
27+
*
28+
* @param Randomizer $randomizer The randomizer used to draw the jitter of each delay.
29+
* @return ExponentialBackoff The created instance.
30+
*/
31+
public static function with(Randomizer $randomizer): ExponentialBackoff
32+
{
33+
return new ExponentialBackoff(randomizer: $randomizer);
34+
}
35+
36+
public function delayFor(int $attempt): int
37+
{
38+
$exponential = (ExponentialBackoff::BASE_MICROSECONDS * (2 ** ($attempt - 1)));
39+
$spread = intdiv(($exponential * ExponentialBackoff::JITTER_PERCENT), 100);
40+
41+
return ($exponential + $this->randomizer->getInt(-$spread, $spread));
42+
}
43+
}

0 commit comments

Comments
 (0)