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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Structured logging via [PSR-3](https://www.php-fig.org/psr/psr-3/): `ClientBuilder::logger()` injects any `Psr\Log\LoggerInterface`; no logger is used by default. Emits `client.initialized` (INFO, once), `http.request.sent` / `http.response.received` (DEBUG, per attempt), and `http.request.failed` (ERROR, transport failures only). Query and JSON-body secrets (`api_key`, `api_secret`, `token`, `password`) are always redacted. Request/response bodies are excluded by default; opt in with `ClientBuilder::logBodies(true)`.
- Opt-in auto-retry policy: `ClientBuilder::retry(new RetryConfig(enabled: true, maxAttempts: 3, maxBackoff: 30.0))`. Retries apply only to `GET`/`HEAD` requests failing with HTTP 429 (unless marked `unrecoverable`) or a transport error, honor `Retry-After` when present, and otherwise back off exponentially with full jitter, both capped at `maxBackoff`. A DEBUG `http.request.failed` event with a `retry.attempt` field is emitted before each retry.

### Changed

- **Behavior change:** the connection-pool configuration is no longer written to PHP's `error_log()` at client construction. That information is now available only through an injected PSR-3 logger, as the `client.initialized` event. If you relied on the old `error_log` line, inject a logger via `->logger(...)` instead.
- **Breaking:** the SDK no longer retries HTTP 429 responses automatically. Previously every request retried up to `maxRetries` (default 3) on 429. Retries are now opt-in via `ClientBuilder::retry(new RetryConfig(enabled: true))` and apply only to `GET`/`HEAD` requests. The `GuzzleHttpClient` constructor's `$maxRetries` parameter is deprecated and ignored; pass a `RetryConfig` instead.

## [9.0.0] - 2026-07-15

Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ Per-call `curl` overrides replace (do not merge with) the client-level `curl` op

**Escape hatch:** Passing your own client via `->httpClient($mine)` skips all 4 knobs; your client is used as-is.

## Retries (opt-in)

Auto-retry is off by default: the client makes exactly one attempt and surfaces errors unchanged. Enable it with `->retry(new RetryConfig(...))`:

```php
$client = GetStream\ClientBuilder::fromEnv()
->retry(new RetryConfig(enabled: true, maxAttempts: 3, maxBackoff: 30.0))
->build();
```

When enabled, a failed attempt is retried only if all of the following hold: the method is `GET` or `HEAD`; the failure is an HTTP 429 (and not marked `unrecoverable`) or a transport error (`StreamTransportException`); and fewer than `maxAttempts` attempts have been made. Writes (`POST`/`PUT`/`PATCH`/`DELETE`) and any other 4xx/5xx status are never retried.

Backoff honors a parsed `Retry-After` header when present (capped at `maxBackoff`, no jitter); otherwise it uses exponential backoff with full jitter, also capped at `maxBackoff`. On exhaustion, the last attempt's error is thrown unchanged.

**Breaking change from earlier versions:** this SDK previously retried HTTP 429 responses on every request automatically (`maxRetries`, default 3). That always-on behavior is gone; retries are now opt-in via `RetryConfig` and apply only to `GET`/`HEAD`.

## Logging

The SDK emits structured events through a [PSR-3](https://www.php-fig.org/psr/psr-3/) `Psr\Log\LoggerInterface`. No logger is injected by default (`Psr\Log\NullLogger`, a no-op); pass your own via `->logger(...)`:
Expand All @@ -76,7 +92,8 @@ Events emitted:
| `client.initialized` | INFO | Once, at construction |
| `http.request.sent` | DEBUG | Before each HTTP attempt |
| `http.response.received` | DEBUG | After any HTTP response, including 4xx/5xx (status codes are data, not a failure) |
| `http.request.failed` | ERROR | Transport failure only (connection reset, timeout, DNS failure, TLS handshake failure) — no HTTP response was received |
| `http.request.failed` | ERROR | Transport failure that surfaces to the caller unchanged (connection reset, timeout, DNS failure, TLS handshake failure) — no HTTP response was received |
| `http.request.failed` | DEBUG | Also emitted, with a `retry.attempt` field, right before each retry backoff sleep (see [Retries](#retries-opt-in)) |

The SDK never sets the logger's minimum level; that's the caller's responsibility.

Expand Down
17 changes: 16 additions & 1 deletion src/ClientBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use GetStream\Http\GuzzleHttpClient;
use GetStream\Http\HttpClientInterface;
use GetStream\Http\PoolConfig;
use GetStream\Http\RetryConfig;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

Expand All @@ -26,6 +27,7 @@ class ClientBuilder
private PoolConfig $pool;
private ?LoggerInterface $logger = null;
private bool $logBodies = false;
private ?RetryConfig $retry = null;

public function __construct()
{
Expand Down Expand Up @@ -140,6 +142,19 @@ public function logBodies(bool $on): self
return $this;
}

/**
* Opt-in auto-retry policy (default: disabled, no retries; the client
* performs exactly one attempt and surfaces errors unchanged). When
* enabled, retries only GET/HEAD requests that fail with HTTP 429 or a
* transport error.
*/
public function retry(RetryConfig $retry): self
{
$this->retry = $retry;

return $this;
}

/**
* Disable loading from environment variables.
*/
Expand Down Expand Up @@ -308,7 +323,7 @@ private function resolveHttpClient(): HttpClientInterface
return $user;
}

$client = new GuzzleHttpClient([], 3, $this->pool, $logger, $this->logBodies);
$client = new GuzzleHttpClient([], 3, $this->pool, $logger, $this->logBodies, $this->retry);

$logger->info('client.initialized', $this->clientInitializedContext(userHttpClient: false));
$this->warnIfLogBodies($logger);
Expand Down
7 changes: 5 additions & 2 deletions src/Exceptions/StreamRateLimitException.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
* `Retry-After` header (integer seconds; `null` if header missing or
* unparseable).
*
* Auto-retry is NOT performed by the SDK. Inspect `getRetryAfter()` and
* compose your own retry strategy (or use a middleware).
* Auto-retry is off by default. Enable the opt-in policy with
* `ClientBuilder::retry(new RetryConfig(enabled: true))`; it retries only
* GET/HEAD requests, honoring this exception's `getRetryAfter()` value.
* Compose your own strategy from `getRetryAfter()` if you need anything
* richer.
*/
class StreamRateLimitException extends StreamApiException
{
Expand Down
160 changes: 121 additions & 39 deletions src/Http/GuzzleHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ class GuzzleHttpClient implements HttpClientInterface
{
private GuzzleClient $client;

/** Maximum number of retries for rate-limited (429) responses. */
private int $maxRetries;

/** @var PoolConfig Effective pool configuration (kept for diagnostics). */
private PoolConfig $pool;

Expand All @@ -38,25 +35,34 @@ class GuzzleHttpClient implements HttpClientInterface
/** Whether request/response bodies are included (key-redacted) in log events. */
private bool $logBodies;

private RetryConfig $retry;

/**
* Create a new GuzzleHttpClient.
*
* @param array $config Guzzle client configuration (wins over $pool defaults)
* @param int $maxRetries Maximum retries for 429 rate-limit responses (default 3).
* @param int $maxRetries Deprecated, ignored: retry behavior is now governed
* entirely by $retry. Kept only for positional-call
* compatibility.
* @param PoolConfig|null $pool Connection pool configuration. When null, spec defaults apply.
* @param LoggerInterface|null $logger PSR-3 logger for structured request/response events. Defaults to NullLogger.
* @param bool $logBodies When true, include (key-redacted) request/response bodies in log events.
* @param RetryConfig|null $retry Opt-in auto-retry policy. When null, retries are disabled
* and the client performs exactly one attempt.
*/
// @phpstan-ignore-next-line constructor.unusedParameter (deprecated, kept for positional-call compatibility)
public function __construct(
array $config = [],
int $maxRetries = 3,
?PoolConfig $pool = null,
?LoggerInterface $logger = null,
bool $logBodies = false,
?RetryConfig $retry = null,
) {
$pool = $pool ?? new PoolConfig();
$this->logger = $logger ?? new NullLogger();
$this->logBodies = $logBodies;
$this->retry = $retry ?? new RetryConfig();

// Persistent multi handle: routes every request (sync and async) through one
// libcurl multi handle that holds a connection pool for the lifetime of this
Expand Down Expand Up @@ -100,7 +106,6 @@ public function __construct(
$merged = array_replace_recursive($defaultConfig, $config);

$this->client = new GuzzleClient($merged);
$this->maxRetries = $maxRetries;
$this->pool = $pool;
}

Expand Down Expand Up @@ -181,6 +186,39 @@ private function emitRequestFailed(
]);
}

/**
* Emit the `http.request.failed` DEBUG event when a failed attempt is about
* to be retried (as opposed to the ERROR-level event emitted for a
* transport failure that surfaces to the caller unchanged).
*/
private function emitRetryAttempt(
string $method,
string $path,
string $query,
StreamRateLimitException | StreamTransportException $e,
int $durationMs,
int $retryAttempt,
): void {
$context = [
'http.request.method' => $method,
'url.path' => $path,
'url.query' => LogRedaction::redactQuery($query),
'error.message' => LogRedaction::redactMessage($e->getMessage()),
'duration_ms' => $durationMs,
'retry.attempt' => $retryAttempt,
];

// `error.type` is a closed transport-failure enum (connection_reset |
// timeout | dns_failure | tls_handshake_failed | unknown); a 429 is a
// received response, not a transport failure, so it has no value in
// that enum and the field is omitted rather than invented.
if ($e instanceof StreamTransportException) {
$context['error.type'] = $e->getErrorType();
}

$this->logger->debug('http.request.failed', $context);
}

/**
* Make an HTTP request.
*
Expand Down Expand Up @@ -229,52 +267,96 @@ public function request(
$parsedQuery = parse_url($url, PHP_URL_QUERY);
$query = is_string($parsedQuery) ? $parsedQuery : '';

// Retry loop for rate-limited (429) responses.
// Opt-in retry policy: disabled (default) means exactly one attempt.
// When enabled, only GET/HEAD requests failing with 429 (recoverable)
// or a transport error are retried; see shouldRetry()/retryDelay().
for ($attempt = 0;; $attempt++) {
$this->emitRequestSent($method, $path, $query, $requestOptions);
$start = hrtime(true);

try {
$response = $this->client->request($method, $url, $requestOptions);
} catch (ClientException | ServerException $e) {
// Reachable only if a caller flipped `http_errors` back to true.
try {
$response = $this->client->request($method, $url, $requestOptions);
} catch (ClientException | ServerException $e) {
// Reachable only if a caller flipped `http_errors` back to true.
$durationMs = self::elapsedMs($start);
$rawBody = $this->emitResponseReceived($e->getResponse(), $durationMs);

return $this->createStreamResponse($e->getResponse(), $e, $rawBody);
} catch (ConnectException $e) {
throw new StreamTransportException($e->getMessage(), self::mapConnectErrorType($e), $e);
} catch (GuzzleException $e) {
throw new StreamTransportException($e->getMessage(), StreamTransportException::ERROR_TYPE_UNKNOWN, $e);
}

$rawBody = $this->emitResponseReceived($response, self::elapsedMs($start));

return $this->createStreamResponse($response, null, $rawBody);
} catch (StreamRateLimitException | StreamTransportException $e) {
$durationMs = self::elapsedMs($start);
$rawBody = $this->emitResponseReceived($e->getResponse(), $durationMs);

return $this->createStreamResponse($e->getResponse(), $e, $rawBody);
} catch (ConnectException $e) {
$transportException = new StreamTransportException(
$e->getMessage(),
self::mapConnectErrorType($e),
$e,
);
$this->emitRequestFailed($method, $path, $query, $transportException, self::elapsedMs($start));

throw $transportException;
} catch (GuzzleException $e) {
$transportException = new StreamTransportException(
$e->getMessage(),
StreamTransportException::ERROR_TYPE_UNKNOWN,
$e,
);
$this->emitRequestFailed($method, $path, $query, $transportException, self::elapsedMs($start));

throw $transportException;
if (!$this->shouldRetry($e, $method, $attempt)) {
if ($e instanceof StreamTransportException) {
$this->emitRequestFailed($method, $path, $query, $e, $durationMs);
}

throw $e;
}

$this->emitRetryAttempt($method, $path, $query, $e, $durationMs, $attempt + 1);
$this->sleepSeconds($this->retryDelay($e, $attempt));
}
}
}

/**
* Decide whether a failed attempt is eligible for retry: retries are
* opt-in (disabled unless a RetryConfig with enabled=true was supplied),
* apply only to GET/HEAD, never to a 429 marked `unrecoverable`, and stop
* once the configured `maxAttempts` is reached.
*/
private function shouldRetry(StreamRateLimitException | StreamTransportException $e, string $method, int $attempt): bool
{
if (!$this->retry->enabled) {
return false;
}
if (!in_array(strtoupper($method), ['GET', 'HEAD'], true)) {
return false;
}
if ($attempt + 1 >= $this->retry->maxAttempts) {
return false;
}
if ($e instanceof StreamRateLimitException) {
return !$e->isUnrecoverable();
}

$rawBody = $this->emitResponseReceived($response, self::elapsedMs($start));
return true;
}

if ($response->getStatusCode() !== 429 || $attempt >= $this->maxRetries) {
return $this->createStreamResponse($response, null, $rawBody);
/**
* Backoff before the next retry: honors a parsed `Retry-After` (no
* jitter) when present and positive, otherwise full jitter over an
* exponential ceiling, both capped at `maxBackoff`.
*/
private function retryDelay(StreamRateLimitException | StreamTransportException $e, int $attempt): float
{
if ($e instanceof StreamRateLimitException) {
$retryAfter = $e->getRetryAfter();
if ($retryAfter !== null && $retryAfter > 0) {
return min((float) $retryAfter, $this->retry->maxBackoff);
}
}

$ceiling = min($this->retry->maxBackoff, 1.0 * (2 ** $attempt));

// Parse Retry-After header or use exponential backoff. Jitter
// desynchronizes parallel test processes to avoid stampedes.
$retryAfter = $response->getHeaderLine('Retry-After');
$sleepSeconds = $retryAfter !== '' ? (int) $retryAfter : ($attempt + 1);
$sleepSeconds = min($sleepSeconds, 10);
$jitter = random_int(0, max(1, (int) round($sleepSeconds * 0.3)));
sleep($sleepSeconds + $jitter);
return $ceiling <= 0 ? 0.0 : mt_rand(0, (int) round($ceiling * 1000)) / 1000.0;
}

/** Overridable so tests can record the delay instead of actually sleeping. */
protected function sleepSeconds(float $seconds): void
{
if ($seconds > 0) {
usleep((int) round($seconds * 1_000_000));
}
}

Expand Down
28 changes: 28 additions & 0 deletions src/Http/RetryConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace GetStream\Http;

/**
* Opt-in auto-retry policy. Disabled by default: the client performs exactly
* one attempt and surfaces errors unchanged. When enabled, only GET/HEAD
* requests failing with HTTP 429 (and not marked unrecoverable) or a
* transport error are retried, honoring `Retry-After` when present and
* falling back to exponential backoff with full jitter otherwise.
*/
final class RetryConfig
{
public function __construct(
public readonly bool $enabled = false,
public readonly int $maxAttempts = 3,
public readonly float $maxBackoff = 30.0,
) {
if ($maxAttempts < 1) {
throw new \InvalidArgumentException("maxAttempts must be >= 1, got {$maxAttempts}");
}
if ($maxBackoff < 0) {
throw new \InvalidArgumentException("maxBackoff must be >= 0, got {$maxBackoff}");
}
}
}
Loading
Loading