Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 150 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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`.
Expand Down Expand Up @@ -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
<?php

declare(strict_types=1);

use GuzzleHttp\Client;
use Psr\Http\Message\RequestInterface;
use Psr\Log\LoggerInterface;
use TinyBlocks\Http\Client\Resilience\AttemptOutcome;
use TinyBlocks\Http\Client\Resilience\FixedDelay;
use TinyBlocks\Http\Client\Resilience\RetryListener;
use TinyBlocks\Http\Client\Resilience\RetryingClient;
use TinyBlocks\Time\Elapsed;

final readonly class LoggingRetryListener implements RetryListener
{
public function __construct(private LoggerInterface $logger)
{
}

public function attemptFailed(
Elapsed $elapsed,
AttemptOutcome $outcome,
RequestInterface $request,
int $attemptNumber
): void {
$this->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
<?php

declare(strict_types=1);

use GuzzleHttp\Psr7\HttpFactory;
use TinyBlocks\Http\Client\Transports\NetworkTransport;
use TinyBlocks\Http\Http;

$http = Http::with(
baseUrl: 'https://api.example.com',
transport: NetworkTransport::with(client: $client, factory: new HttpFactory())
);
```

#### Backoff policies

`Backoff` computes the delay, in microseconds, slept before the next attempt. Two implementations ship with the
library. Implement the interface for any other curve.

`FixedDelay` waits the same delay before every retry:

```php
FixedDelay::ofMicroseconds(microseconds: 500000); # always 500 ms
```

`ExponentialBackoff` doubles a base delay of 100 ms on every attempt and spreads it with a uniformly random jitter of
up to 30 percent of that value in either direction, keeping concurrent clients from retrying in lockstep against a
recovering dependency:

```php
<?php

declare(strict_types=1);

use Random\Randomizer;
use TinyBlocks\Http\Client\Resilience\ExponentialBackoff;

$backoff = ExponentialBackoff::with(randomizer: new Randomizer());

$backoff->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
<?php

declare(strict_types=1);

use GuzzleHttp\Client;
use TinyBlocks\Http\Client\HeaderSettingClient;

$client = HeaderSettingClient::with(client: new Client(), headerValues: [
'Correlation-Id' => 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.
Expand Down Expand Up @@ -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
<?php
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"psr/http-client": "^1.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^2.0",
"tiny-blocks/mapper": "^3.1"
"tiny-blocks/mapper": "^3.1",
"tiny-blocks/time": "^2.3"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.52",
Expand Down
59 changes: 59 additions & 0 deletions src/Client/HeaderSettingClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Client;

use Closure;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
* PSR-18 decorator that sets headers on every outbound request, resolving each value at send time.
*
* <p>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.</p>
*/
final readonly class HeaderSettingClient implements ClientInterface
{
/**
* @param ClientInterface $client The underlying client to delegate sends to.
* @param array<string, Closure(): string> $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<string, Closure(): string> $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);
}
}
70 changes: 70 additions & 0 deletions src/Client/Resilience/AttemptOutcome.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Client\Resilience;

use Throwable;
use TinyBlocks\Http\Code;

/**
* Classification of a failed HTTP attempt, driving whether a retry is worthwhile.
*/
enum AttemptOutcome: string
{
case TIMEOUT = 'timeout';
case CLIENT_ERROR = '4xx';
case SERVER_ERROR = '5xx';
case CONNECTION_RESET = 'connection_reset';

/**
* Creates an AttemptOutcome from a transport failure.
*
* <p>A failure whose message mentions a timeout classifies as {@see AttemptOutcome::TIMEOUT}.
* Every other transport failure classifies as {@see AttemptOutcome::CONNECTION_RESET}.</p>
*
* @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.
*
* <p>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.</p>
*
* @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;
}
}
23 changes: 23 additions & 0 deletions src/Client/Resilience/Backoff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Client\Resilience;

/**
* Delay policy applied between retry attempts.
*
* <p>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).</p>
*/
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;
}
43 changes: 43 additions & 0 deletions src/Client/Resilience/ExponentialBackoff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Client\Resilience;

use Random\Randomizer;

/**
* Backoff that doubles a base delay of 100 milliseconds on every attempt and spreads it with random jitter.
*
* <p>The delay for attempt N is <code>100ms * 2^(N - 1)</code>, 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.</p>
*/
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));
}
}
Loading