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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@
`latest()`, the historical period methods and `demoPrices()`. Legitimate
zero and negative prices are preserved, and a genuinely empty `prices` list
still returns an empty array.
- Stop retrying an exhausted durable quota. A rate-limit response carrying
`MONTHLY_QUOTA_EXCEEDED`, `TRIAL_LIMIT_EXCEEDED`, `TRIAL_EXPIRED`,
`EMAIL_CONFIRMATION_REQUIRED` or `DEMO_RATE_LIMIT_EXCEEDED` now fails fast
instead of being retried against a limit that is already spent; burst and
circuit-breaker limits are still retried.
- Never shorten `Retry-After`. A delay longer than the client's retry budget
raises `RateLimitException` carrying the server's own delay instead of coming
back early, and a negative delta-seconds value falls back to normal backoff
rather than a zero-second hot retry.

## 2.1.2 (2026-08-11)

Expand Down
89 changes: 81 additions & 8 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ final class Client
/** Maximum backoff sleep between retries, in seconds. */
private const MAX_BACKOFF_SECONDS = 30.0;

/**
* Error codes for limits that do not refill inside any backoff this client
* could sleep. Retrying one of these cannot succeed - it only spends more
* requests against a limit that is already exhausted. Compared
* case-insensitively against `error_code`, `error.code` and `code`.
*
* @var list<string>
*/
private const DURABLE_QUOTA_CODES = [
'MONTHLY_QUOTA_EXCEEDED',
'DAILY_QUOTA_EXCEEDED',
'QUOTA_EXCEEDED',
'TRIAL_LIMIT_EXCEEDED',
'TRIAL_EXPIRED',
'EMAIL_CONFIRMATION_REQUIRED',
'DEMO_RATE_LIMIT_EXCEEDED',
];

private readonly ?string $apiKey;
private readonly string $baseUrl;
private readonly HttpTransport $transport;
Expand Down Expand Up @@ -303,11 +321,20 @@ private function request(string $rawPath, array $params): array
for ($attempt = 0; $attempt < $attempts; $attempt++) {
$response = $this->transport->request('GET', $url, $headers, $this->timeout);

if (!$this->isRetryable($response->statusCode) || $attempt === $attempts - 1) {
if (!$this->isRetryable($response) || $attempt === $attempts - 1) {
break;
}

($this->sleeper)($this->backoffDelay($attempt, $response));
$delay = $this->retryDelay($attempt, $response);
if ($delay === null) {
// The server asked us to wait longer than this client's budget.
// Coming back early would be a second refusal, so stop and let
// the caller schedule the retry with the guidance on the
// exception.
break;
}

($this->sleeper)($delay);
}

assert($response instanceof HttpResponse);
Expand Down Expand Up @@ -405,19 +432,56 @@ private function offOriginPath(string $path, string $reason): ApiException
));
}

private function isRetryable(int $statusCode): bool
private function isRetryable(HttpResponse $response): bool
{
return $statusCode === 429 || $statusCode >= 500;
if ($response->statusCode >= 500) {
return true;
}

return $response->statusCode === 429 && !$this->isDurableQuotaExhausted($response);
}

/**
* Whether a 429 reports a limit that will not refill inside a retry window.
*/
private function isDurableQuotaExhausted(HttpResponse $response): bool
{
$decoded = json_decode($response->body, true);
if (!is_array($decoded)) {
return false;
}

$candidates = [
$decoded['error_code'] ?? null,
$decoded['code'] ?? null,
is_array($decoded['error'] ?? null) ? ($decoded['error']['code'] ?? null) : null,
];

foreach ($candidates as $candidate) {
if (is_string($candidate) && in_array(strtoupper(trim($candidate)), self::DURABLE_QUOTA_CODES, true)) {
return true;
}
}

return false;
}

/**
* Exponential backoff with full jitter, honoring Retry-After when present.
* Delay before the next attempt, or null when the server's minimum delay
* exceeds this client's budget and the request must not be retried.
*
* Exponential backoff with full jitter when the server gave no usable
* instruction; the server's own Retry-After wins when it did.
*/
private function backoffDelay(int $attempt, HttpResponse $response): float
private function retryDelay(int $attempt, HttpResponse $response): ?float
{
$retryAfter = $this->parseRetryAfter($response);
if ($retryAfter !== null) {
return min((float) $retryAfter, self::MAX_BACKOFF_SECONDS);
if ($retryAfter > self::MAX_BACKOFF_SECONDS) {
return null;
}

return (float) $retryAfter;
}

$base = min(0.5 * (2 ** $attempt), self::MAX_BACKOFF_SECONDS);
Expand All @@ -426,6 +490,13 @@ private function backoffDelay(int $attempt, HttpResponse $response): float
return min($base + $jitter, self::MAX_BACKOFF_SECONDS);
}

/**
* Retry-After in seconds, or null when absent or malformed.
*
* A negative delta-seconds value is malformed, not an instruction to retry
* immediately, so it is discarded in favour of normal backoff. An HTTP-date
* already in the past does mean "now", and becomes 0.
*/
private function parseRetryAfter(HttpResponse $response): ?int
{
$value = $response->header('Retry-After');
Expand All @@ -434,7 +505,9 @@ private function parseRetryAfter(HttpResponse $response): ?int
}

if (is_numeric($value)) {
return max(0, (int) $value);
$seconds = (int) $value;

return $seconds < 0 ? null : $seconds;
}

$timestamp = strtotime($value);
Expand Down
Loading