A skill for implementing production-grade rate limiting, exponential backoff, and retry strategies when integrating with external APIs. Prevents cascading failures, respects upstream quotas, and keeps your application resilient under load.
Retry-After headersDetermine whether a failed request is retryable or terminal.
| Status | Classification | Action |
|---|---|---|
| 200-299 | Success | Return response |
| 400, 401, 403, 404 | Terminal client error | Do not retry — fix the request |
| 408, 429 | Retryable (rate limit / timeout) | Retry with backoff |
| 500, 502, 503, 504 | Retryable (server error) | Retry with backoff |
Always check upstream hints before computing your own delay.
function getRetryDelay(
response: Response,
attempt: number,
maxDelayMs = 60_000
): number {
// Prefer upstream hints
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) {
return seconds * 1000;
}
// HTTP-date format
const date = new Date(retryAfter).getTime();
if (Number.isFinite(date)) {
return Math.max(0, date - Date.now());
}
}
// GitHub documents x-ratelimit-reset as Unix epoch seconds.
const resetHeader = response.headers.get("x-ratelimit-reset");
const githubReset = resetHeader === null || resetHeader.trim() === "" ? NaN : Number(resetHeader);
if (Number.isFinite(githubReset)) {
return Math.max(0, githubReset * 1000 - Date.now());
}
// Fallback: capped exponential backoff with full jitter.
const cap = Math.min(1000 * 2 ** attempt, maxDelayMs);
return Math.floor(Math.random() * cap);
}
Upstream hints are minimum waits; never truncate them to retry early. If a hint exceeds the remaining operation budget, stop without scheduling another request. The cap applies only to fallback backoff.
Provider-specific reset headers do not share one unit or format. For example,
some APIs return durations while GitHub returns epoch seconds. Parse an
additional header only after checking that provider's current documentation.
async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3,
maxElapsedMs = 120_000,
retryNonIdempotent = false
): Promise<Response> {
const startedAt = Date.now();
const method = (options.method ?? "GET").toUpperCase();
const replaySafe = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"].includes(method)
|| retryNonIdempotent;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.ok) return response;
// Terminal errors — do not retry
if (![408, 429, 500, 502, 503, 504].includes(response.status)) {
throw new Error(`Terminal error ${response.status}: ${response.statusText}`);
}
if (!replaySafe) {
throw new Error(
`${method} was not retried because replay safety was not explicitly established`
);
}
// Retryable — but exhausted attempts
if (attempt === maxRetries) {
throw new Error(`Failed after ${maxRetries} retries: ${response.status}`);
}
const remaining = maxElapsedMs - (Date.now() - startedAt);
const delay = getRetryDelay(response, attempt);
if (remaining <= 0 || delay > remaining) {
throw new Error(`Retry cannot be scheduled within ${maxElapsedMs}ms`);
}
// Release the connection before waiting when the body is not needed.
await response.body?.cancel();
console.warn(
`Request failed (${response.status}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxRetries})`
);
await new Promise(resolve => setTimeout(resolve, delay));
}
throw new Error("Unreachable");
}
Prevent hitting upstream limits in the first place with a token bucket or sliding window.
class TokenBucket {
private tokens: number;
private lastRefill: number;
private queue: Promise<void> = Promise.resolve();
constructor(
private maxTokens: number,
private refillRate: number // tokens per second
) {
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
async acquire(): Promise<void> {
const ticket = this.queue.then(() => this.acquireOnce());
this.queue = ticket.catch(() => undefined);
return ticket;
}
private async acquireOnce(): Promise<void> {
this.refill();
if (this.tokens < 1) {
const waitMs = ((1 - this.tokens) / this.refillRate) * 1000;
await new Promise(resolve => setTimeout(resolve, waitMs));
this.refill();
}
this.tokens -= 1;
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
}
// Usage: limit to 60 requests/minute
const limiter = new TokenBucket(60, 1);
async function rateLimitedFetch(url: string, options: RequestInit) {
await limiter.acquire();
return fetchWithRetry(url, options);
}
const response = await fetchWithRetry(
"https://api.github.com/repos/OWNER/REPO",
{
method: "GET",
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${githubToken}`,
},
},
3
);
For a POST or another operation with side effects, leaveretryNonIdempotent false unless the provider documents an idempotency
mechanism and the same stable idempotency key is reused for every attempt.
import time
import random
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import httpx
def fetch_with_retry(url: str, max_retries: int = 3, max_elapsed_s: float = 120, **kwargs) -> httpx.Response:
started = time.monotonic()
kwargs.setdefault("timeout", 10.0)
for attempt in range(max_retries + 1):
response = httpx.request("GET", url, **kwargs)
if response.is_success:
return response
if response.status_code not in (408, 429, 500, 502, 503, 504) or attempt == max_retries:
response.raise_for_status()
retry_after = response.headers.get("retry-after")
delay = min(2 ** attempt + random.uniform(0, 1), 60)
if retry_after:
try:
seconds = float(retry_after)
if seconds >= 0 and seconds < float("inf"):
delay = seconds
except ValueError:
try:
retry_at = parsedate_to_datetime(retry_after)
delay = max(0, (retry_at - datetime.now(timezone.utc)).total_seconds())
except (ValueError, TypeError, OverflowError):
pass
remaining = max_elapsed_s - (time.monotonic() - started)
if remaining <= 0 or delay > remaining:
response.close()
raise TimeoutError("Retry cannot fit the remaining operation budget")
response.close()
time.sleep(delay)
raise RuntimeError("Unreachable")
Retry-After headers — they come from the provider who knows their limitsupstash-ratelimit skill, or any shared-store limiter).AbortSignal or client timeout.Solution: Use exponential backoff with jitter and a circuit breaker for sustained failures.
Solution: Add randomized jitter (Math.random() * 0.3 * delay) to decorrelate retries.
Solution: Parse both formats — check if the value is numeric first, then try Date parsing.
Solution: Serialize acquisition within one process, decrement before send, and use a shared distributed limiter across instances.
@poka-yoke - Mistake-proofing APIs so invalid requests never reach the retry path@circuit-breaker - When to stop retrying entirely and fail fast