Idempotency & Retry-Safe Systems · Retries Done Right
Retry Policies for Clients and SDKs
Ship safe defaults in your SDKs and document retry behavior for API consumers.
If you publish an API, your SDKs define how millions of clients retry. Good defaults: retry only retryable errors, 2–3 attempts, exponential backoff with jitter, honor Retry-After, automatically attach idempotency keys to unsafe requests (reused across that request's retries), respect an overall timeout, and expose hooks for logging. Document the policy so integrators without SDKs implement the same behavior.
SDK retry defaults
| Setting | Recommended default |
|---|---|
| Max retries | 2–3 |
| Backoff | Exponential with full jitter, capped |
| Retryable | Network errors, 408, 409 (in progress), 429, 5xx |
| Unsafe requests | Auto Idempotency-Key, reused across attempts |
| Overall timeout | Configurable (e.g., 60 s) |
async request(method: string, path: string, body?: unknown, opts: { idempotencyKey?: string } = {}) {
const key = method === "POST" ? opts.idempotencyKey ?? crypto.randomUUID() : undefined; // one key for all attempts
for (let attempt = 0; ; attempt++) {
const res = await fetch(this.base + path, {
method, body: body && JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs),
headers: { "Content-Type": "application/json", ...(key && { "Idempotency-Key": key }) },
}).catch((e) => e as Error);
const status = res instanceof Error ? null : res.status;
if (status !== null && status < 400) return (res as Response).json();
if (attempt >= this.maxRetries || !shouldRetry(method, status, Boolean(key))) throw toApiError(res);
await sleep(retryAfterMs(res) ?? fullJitter(attempt));
}
}
def request(self, method, path, json=None, idempotency_key=None):
key = idempotency_key or (str(uuid.uuid4()) if method == "POST" else None)
for attempt in range(self.max_retries + 1):
try:
resp = self._http.request(method, path, json=json, timeout=self.timeout,
headers={"Idempotency-Key": key} if key else {})
except httpx.TransportError:
resp = None
status = resp.status_code if resp is not None else None
if status is not None and status < 400:
return resp.json()
if attempt == self.max_retries or not should_retry(method, status, key is not None):
raise api_error(resp)
time.sleep(retry_after(resp) or full_jitter(attempt))
Common pitfalls
- SDKs that retry POST without keys.
- A new key per attempt.
- Hidden retries that users can't configure or observe.
Key points
- Safe defaults in SDKs
- Auto idempotency keys
- Configurable and documented
Further reading: Stripe: Idempotency and retries
Practice questions
Why should an SDK generate the idempotency key before the first attempt?
Answer
All retries of that request must share the same key; generating it per attempt would make each retry a new operation.
Which errors should SDKs retry by default?
Answer
Network failures and timeouts, 408, 409 'in progress', 429 and 5xx — but unsafe methods only when an idempotency key is attached.
Open in the app to mark lessons complete, take notes and track your progress.