{"id":298,"date":"2026-09-22T17:47:54","date_gmt":"2026-09-22T09:47:54","guid":{"rendered":"https:\/\/wp.qoraapi.com\/idempotency-safe-retries-ai-api\/"},"modified":"2026-09-22T17:49:29","modified_gmt":"2026-09-22T09:49:29","slug":"idempotency-safe-retries-ai-api","status":"publish","type":"post","link":"https:\/\/qoraapi.com\/blog\/idempotency-safe-retries-ai-api\/","title":{"rendered":"Idempotency and Safe Retries for AI APIs"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A retry is a write, not a read. Resending after a timeout asserts that the first attempt either never executed or produced no effect you care about, and against LLM APIs that is usually false. Correct retries need two mechanisms: a classifier that separates provably-unsent failures from unknown-outcome ones, and an idempotency key that collapses duplicate attempts into one execution. Backoff, jitter and budgets only stop you making an outage worse while you wait.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why a client-side timeout tells you nothing about the server<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Your HTTP client gives up at 30 seconds. The provider does not know that. For a non-streaming completion the request is already sent, the model is mid-generation, and nothing propagates your disconnect into the inference worker. The generation finishes, the output tokens are counted, and you are billed for all of them. You received nothing. In your own data this shows up as billed completion tokens with no delivered response, and it means your retry policy is paying twice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Worked example. A model at $3.00 per million output tokens, completions averaging 1,800 tokens, a client timeout at 30 s against a real server-side latency of 34 s. One affected request costs 1,800 \/ 1,000,000 x $3.00 = $0.0054 and returns zero bytes. Two retries cost 2 x $0.0054 = $0.0108. At 20,000 affected requests a day that is $216 daily, roughly $6,480 a month, for nothing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A failure taxonomy you can actually code against<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Retry decisions turn on four axes: transport outcome, HTTP status, provider error type, and semantics. Broken retry code inspects one, usually the status code.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Transport failures<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">DNS failure, connection refused and TLS handshake failure are the only provably pre-send failures: no HTTP request was written, so nothing can have executed. Everything else is unknown-outcome. A reset before response headers is unknown: an edge proxy can accept and forward before resetting. The one question that matters: did any response byte arrive?<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">HTTP status classes<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">408, 429, 500, 502, 503 and 504 are retryable. 400, 401, 403, 404, 413 and 422 are not, because identical bytes produce an identical rejection. 401 gets one exception: a single retry after a credential refresh that changed the credential. Never treat any 5xx as retryable without reading the body.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Provider-specific overload signals<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is where generic libraries fail. OpenAI returns 429 for both rate limiting and quota exhaustion, and the two need opposite responses: <code>rate_limit_exceeded<\/code> is retryable, <code>insufficient_quota<\/code> is a billing wall that never clears. Same status, opposite decision, distinguishable only from the body&#8217;s <code>type<\/code> field. Anthropic uses 529 <code>overloaded_error<\/code>, outside the standard set, so a <code>status in {500, 502, 503, 504}<\/code> check misses it. Vertex and Gemini return <code>RESOURCE_EXHAUSTED<\/code> and <code>UNAVAILABLE<\/code> in a gRPC envelope, and some streaming paths deliver the error in-band on HTTP 200. Bedrock raises <code>ThrottlingException<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That compounds badly: the OpenAI Python SDK retries twice by default, Anthropic&#8217;s twice, boto3&#8217;s adaptive mode more. Add an application layer with three attempts and one logical operation produces 3 x 4 = 12 upstream calls. Two retry layers multiply; they do not add.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Semantic failures<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Content-filter blocks, context-length overflows, refusals and schema violations are deterministic in the input, so retrying is pure cost. At temperature above zero a filter false-positive is stochastic, and re-issuing at temperature zero can pass. That is a new request with different parameters, not a retry.<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\"><thead><tr><th>Condition<\/th><th>Retryable?<\/th><th>Action<\/th><\/tr><\/thead><tbody><tr><td>DNS failure, connection refused, TLS handshake failure<\/td><td>Yes, safely<\/td><td>Jittered retry; provably pre-send, no duplicate risk<\/td><\/tr><tr><td>Connection reset before response headers<\/td><td>Yes, unknown outcome<\/td><td>Retry only with an idempotency key<\/td><\/tr><tr><td>Read timeout after headers received<\/td><td>Unknown<\/td><td>Treat as executed; require key, log orphan tokens<\/td><\/tr><tr><td>408 Request Timeout<\/td><td>Unknown<\/td><td>Key required; honour any Retry-After<\/td><\/tr><tr><td>429 <code>rate_limit_exceeded<\/code><\/td><td>Yes<\/td><td>Honour Retry-After, jitter, reduce concurrency<\/td><\/tr><tr><td>429 <code>insufficient_quota<\/code><\/td><td>No<\/td><td>Fail fast, page the billing owner<\/td><\/tr><tr><td>500 Internal Server Error<\/td><td>Usually<\/td><td>Jittered retry; open breaker if sustained<\/td><\/tr><tr><td>502 Bad Gateway<\/td><td>Yes<\/td><td>One immediate retry, then backoff<\/td><\/tr><tr><td>503 \/ 504<\/td><td>Yes, unknown outcome<\/td><td>Backoff plus key; shed load if persistent<\/td><\/tr><tr><td>529 <code>overloaded_error<\/code> (Anthropic)<\/td><td>Yes<\/td><td>Long backoff, expect minutes not seconds<\/td><\/tr><tr><td><code>ThrottlingException<\/code> (Bedrock)<\/td><td>Yes<\/td><td>Disable SDK retries first, then own it<\/td><\/tr><tr><td>400 \/ 422 malformed request<\/td><td>No<\/td><td>Fix the caller; retrying wastes budget<\/td><\/tr><tr><td>401 \/ 403<\/td><td>No, unless refreshed<\/td><td>Refresh credential once, then fail<\/td><\/tr><tr><td>404 model not found<\/td><td>No<\/td><td>Fix model identifier or routing table<\/td><\/tr><tr><td>413 payload too large<\/td><td>No<\/td><td>Truncate, chunk, or switch model<\/td><\/tr><tr><td>Content filter \/ refusal<\/td><td>No<\/td><td>Different prompt or parameter set, new request<\/td><\/tr><tr><td><code>context_length_exceeded<\/code><\/td><td>No<\/td><td>Truncate or route to a longer-context model<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Exponential backoff with full jitter<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ship full jitter: <code>delay = uniform(0, min(cap, base * 2 ** attempt))<\/code>, attempt zero-indexed. Worked example, base 0.5 s, cap 20 s. Attempts 0 to 5 cap the delay at 0.5, 1, 2, 4, 8 and 16 s, with means of 0.25, 0.5, 1, 2, 4 and 8 s. Attempt 6 onward caps at min(20, 64) = 20 s, mean 10 s. A three-attempt policy adds an expected 0.25 + 0.5 = 0.75 s of sleep.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Jitter exists because failures are correlated. Without it, every client that hit the same blip retries at the same instant and the retry wave recreates the overload. Deterministic backoff also synchronises with recovery: if a provider sheds load for two seconds, every client that picked a two-second delay returns exactly as the queue drains. AWS&#8217;s published simulation of these variants found full jitter won on both request count and completion time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Set the cap against your request budget: a 20-second sleep inside a 30-second client timeout means the retry never executes. Cap interactive calls at 20 s and batch at 60 s, and enforce a wall-clock budget per logical operation including every sleep. Honour <code>Retry-After<\/code> when present, clamped to the cap. Retries also need a budget: a token bucket of 100 tokens refilling 0.1 per successful request bounds sustained retry traffic at roughly 10% of your success rate.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import random\n\nRETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}\n\n# Overload signals that are retryable despite non-standard codes or envelopes.\nRETRYABLE_TYPES = {\n    \"rate_limit_exceeded\", \"overloaded_error\", \"server_error\",\n    \"ThrottlingException\", \"RESOURCE_EXHAUSTED\", \"UNAVAILABLE\",\n}\n\n# Signals that share a status code with a retryable error but never clear.\nFATAL_TYPES = {\n    \"insufficient_quota\", \"billing_hard_limit_reached\", \"invalid_api_key\",\n    \"invalid_request_error\", \"content_policy_violation\",\n    \"context_length_exceeded\",\n}\n\nclass RetryBudget:\n    \"\"\"Token bucket that caps retries at a fraction of successful traffic.\"\"\"\n\n    def __init__(self, capacity: int = 100, refill_per_success: float = 0.1):\n        self.capacity = capacity\n        self.tokens = float(capacity)\n        self.refill = refill_per_success\n\n    def grant(self) -&gt; bool:\n        if self.tokens &lt; 1.0:\n            return False\n        self.tokens -= 1.0\n        return True\n\n    def on_success(self) -&gt; None:\n        self.tokens = min(self.capacity, self.tokens + self.refill)\n\ndef classify(status=None, provider_type=None, exc=None, headers_received=False):\n    \"\"\"Return (retryable, provably_pre_send, reason).\"\"\"\n    if exc is not None:\n        if isinstance(exc, TimeoutError):\n            # No headers means the request may or may not have executed.\n            return (not headers_received), False, \"timeout\"\n        if isinstance(exc, ConnectionRefusedError) and not headers_received:\n            return True, True, \"connection_refused\"\n        if isinstance(exc, OSError) and not headers_received:\n            return True, False, \"transport_error\"\n        return False, False, \"unexpected_exception\"\n\n    if provider_type in FATAL_TYPES:\n        return False, False, \"provider:\" + str(provider_type)\n    if status in (401, 403):\n        return False, False, \"auth\"\n    if status is not None and 400 &lt;= status &lt; 500 and status not in RETRYABLE_STATUS:\n        return False, False, \"client_error_\" + str(status)\n    if status in RETRYABLE_STATUS or provider_type in RETRYABLE_TYPES:\n        return True, False, \"status_\" + str(status)\n    return False, False, \"unclassified_\" + str(status)\n\ndef full_jitter(attempt: int, base: float = 0.5, cap: float = 20.0) -&gt; float:\n    \"\"\"attempt is zero-indexed: uniform(0, min(cap, base * 2**attempt)).\"\"\"\n    return random.uniform(0.0, min(cap, base * (2 ** attempt)))\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Idempotency keys: one key per logical operation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The key identifies intent, not an attempt. Generate it once before the first send, persist it, and reuse it byte-for-byte on every retry. Where the operation has a business identity, derive it deterministically: <code>sha256(tenant_id + \"generate_summary\" + document_id + revision_id)<\/code> survives restarts, deployments and queue redelivery. Where no identity exists, generate a UUIDv7 or ULID once and store it in the row that represents the operation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Do not hash the request body: two identical prompts from different tenants are different operations, and a retry that re-serialises the message list in a different order would compute a different key. Store a body fingerprint beside the key so reuse with a changed payload is a hard error. Keep state in Redis with <code>SET key state NX EX 86400<\/code>: <code>in_progress<\/code> with a short lease so a crashed worker cannot wedge the key, <code>completed<\/code> with the response body for transparent replays, <code>failed<\/code> with the terminal error class. The TTL must exceed the worst-case client retry window.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Replay semantics must be explicit. Completed: return the stored response with its original status plus a marker header such as <code>Idempotency-Replayed: true<\/code>. In flight: 409 Conflict or 202 with a polling location, never blocking the second request on the first. Same key, different fingerprint: 422, hard fail. Terminally failed: store the error class but not the response, and let the caller re-key after fixing the input.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Provider support is uneven: some providers document an idempotency header on some endpoints, many document nothing, and support differs between streaming and non-streaming paths. Terminate idempotency in your own layer, so one component owns the dedupe table and forwards at most one upstream dispatch per key. That is the strongest argument for putting a gateway in front of providers, whether <a href=\"https:\/\/qoraapi.com\/\">qoraapi.com<\/a> or a proxy you run yourself: it becomes the single place the key is enforced. A gateway guarantees at-most-once dispatch, not at-most-once execution; if it times out upstream, the outcome is still unknown.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import { createHash, randomUUID } from \"node:crypto\";\n\ntype Stored = {\n  state: \"in_progress\" | \"completed\" | \"failed\";\n  fingerprint: string;\n  status?: number;\n  body?: unknown;\n  errorClass?: string;\n};\n\nconst TTL_SECONDS = 86_400;\n\nexport function deriveKey(tenantId: string, operation: string, eventId: string): string {\n  \/\/ Stable across restarts, deployments and queue redelivery.\n  return createHash(\"sha256\")\n    .update(`${tenantId}:${operation}:${eventId}`)\n    .digest(\"hex\");\n}\n\nexport function newKey(): string {\n  \/\/ No natural business identity: generate once, persist with the operation row.\n  return randomUUID();\n}\n\nfunction fingerprint(payload: unknown): string {\n  return createHash(\"sha256\").update(JSON.stringify(payload)).digest(\"hex\");\n}\n\nexport async function withIdempotency(\n  redis: any,\n  key: string,\n  payload: unknown,\n  exec: () =&gt; Promise&lt;{ status: number; body: unknown }&gt;,\n) {\n  const fp = fingerprint(payload);\n  const claim = await redis.set(\n    `idem:${key}`,\n    JSON.stringify({ state: \"in_progress\", fingerprint: fp } satisfies Stored),\n    \"NX\",\n    \"EX\",\n    TTL_SECONDS,\n  );\n\n  if (claim === null) {\n    const prev: Stored = JSON.parse(await redis.get(`idem:${key}`));\n\n    if (prev.fingerprint !== fp) {\n      return { status: 422, body: { error: \"idempotency_key_reused_with_different_payload\" } };\n    }\n    if (prev.state === \"in_progress\") {\n      return { status: 409, body: { error: \"operation_in_progress\", key } };\n    }\n    if (prev.state === \"completed\") {\n      return { status: prev.status ?? 200, body: prev.body, replayed: true };\n    }\n    return { status: 409, body: { error: \"previous_attempt_failed\", errorClass: prev.errorClass } };\n  }\n\n  try {\n    const result = await exec();\n    await redis.set(\n      `idem:${key}`,\n      JSON.stringify({ state: \"completed\", fingerprint: fp, ...result } satisfies Stored),\n      \"EX\",\n      TTL_SECONDS,\n    );\n    return result;\n  } catch (err: any) {\n    await redis.set(\n      `idem:${key}`,\n      JSON.stringify({\n        state: \"failed\",\n        fingerprint: fp,\n        errorClass: err?.code ?? \"unknown\",\n      } satisfies Stored),\n      \"EX\",\n      TTL_SECONDS,\n    );\n    throw err;\n  }\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Idempotency for streaming responses<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A partially consumed stream cannot be retried transparently, for three independent reasons. Sampling is stochastic, so a retry produces a different completion and you cannot dedupe by comparing text. Token accounting arrives in the terminal chunk, so an aborted stream leaves you with no authoritative billing number. And whether the provider keeps generating after your disconnect varies by provider.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Resume works only when the provider exposes a stable response id plus a continuation endpoint, which most do not. <code>Last-Event-ID<\/code> resumption is a property of your own SSE stream, not the provider&#8217;s token stream: you can resume delivery from a server-side buffer, but only if you kept the buffer. Restart with client-side dedupe suits machine consumers that can discard a partial result, and is wrong for chat UIs where the user already watched tokens appear. Accepting the loss means marking the message failed, regenerating, and recording the duplicate spend. What I would ship: buffer server-side, do not emit until the first chunk is committed, persist accumulated text against the key, and on retry offer an explicit regenerate action rather than a silent retry. Delivery mechanics are in <a href=\"https:\/\/qoraapi.com\/blog\/ai-api-streaming-sse\/\">AI API streaming and SSE<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Duplicate side effects are worse than duplicate spend<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Money is recoverable. A sent email, a created ticket or a shipped order is not. Every tool that writes, sends or charges is non-idempotent by default, and a model that sees a tool error will call it again.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use natural keys where a business identity exists: <code>sha256(tenant_id + \"send_invoice_email\" + invoice_id)<\/code> is stable across restarts, deployments and replays. Otherwise the dedupe table is the primitive that works. A unique constraint plus <code>INSERT ... ON CONFLICT DO NOTHING<\/code> with a rowcount check gives exactly one winner under concurrency; an in-process lock or a Redis GET-then-SET has a race window and fails precisely when two workers retry simultaneously.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For effects that must be atomic with a database state change, use the outbox pattern: write the intent into the outbox in the same transaction as the state change, drain it with at-least-once delivery, and make the downstream effect idempotent by passing your key onward. For irreversible external effects, use two-phase confirmation: insert a <code>pending<\/code> row carrying your key, call the downstream API with that key, then mark <code>committed<\/code> with the downstream identifier. A timeout leaves a pending row that tells you what to reconcile. Two rules for review: the key never comes from model output, and retry permission is declared by the tool author rather than inferred. Related patterns are in <a href=\"https:\/\/qoraapi.com\/blog\/ai-agents-tool-use\/\">AI agents and tool use<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Circuit breakers, bulkheads and hedging<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If every request is failing and each retries three times, you have tripled load on a system already shedding. A provider&#8217;s 429 is an instruction to reduce concurrency, not increase attempts. Aggregate retries without concurrency control are a self-inflicted denial of service.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Open the breaker on either N consecutive failures or a failure ratio above a threshold over a rolling window, with a minimum sample of 20 requests. Stay open 30 seconds initially, longer for provider-wide outages. Half-open admits exactly one probe, and the probe should be cheap, a models listing or a one-token completion rather than a real user request. If it fails, multiply the cooldown (30 s, 60 s, 120 s, capped at 5 minutes) instead of re-probing every 30 seconds.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Bulkheads are the other half: a semaphore per provider limiting in-flight requests, which retries must acquire through. If the retry path bypasses the cap, retries become the load. On 429s, halve the limit and recover at roughly 10% per minute. Use separate connection pools per provider so one provider&#8217;s slow responses cannot starve another&#8217;s sockets. The multi-provider version is in <a href=\"https:\/\/qoraapi.com\/blog\/ai-api-failover-multi-provider\/\">AI API failover across multiple providers<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Hedging sends a duplicate to a second provider after a latency threshold, takes the first response, and cancels the loser. It works when the tail is dominated by queueing rather than work. Set the threshold near your p95: if p50 is 1.2 s and p95 is 3.5 s, hedge at 3.5 s, so only a few percent of requests spawn a second call. It is wrong for anything with side effects, because hedging is a duplicate by design, and wrong when the slow request is slow because the model is reasoning: cancelling at 3.5 s discards a 40-second generation and you pay for the tokens already produced. Cancellation is not a refund.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cost arithmetic. 1,000,000 daily requests, a 5% hedge rate, so 50,000 hedges. If 60% return first, you still pay for the original&#8217;s partial generation. Worst case you pay for 1,050,000 generations instead of 1,000,000: a 5% spend increase for a tail-latency win. Good trade for a user-facing chat; not for a batch job where nobody is waiting.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A retry helper that classifies, budgets, and refuses unkeyed retries<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>import asyncio\nimport time\nfrom dataclasses import dataclass\n\nclass RetryBudgetExhausted(RuntimeError):\n    pass\n\n@dataclass\nclass AttemptLog:\n    logical_id: str\n    attempt: int\n    reason: str\n    status: int | None\n    provider_type: str | None\n    retry_after: float | None\n    delay_ms: int\n    outcome: str\n\ndef extract_error_fields(exc):\n    \"\"\"Pull status, provider error type and Retry-After out of an SDK exception.\"\"\"\n    status = getattr(exc, \"status_code\", None) or getattr(exc, \"http_status\", None)\n    ptype = None\n    body = getattr(exc, \"body\", None)\n    if isinstance(body, dict):\n        err = body.get(\"error\", body)\n        if isinstance(err, dict):\n            ptype = err.get(\"type\") or err.get(\"code\")\n    if ptype is None:\n        ptype = getattr(exc, \"code\", None)\n\n    retry_after = None\n    headers = getattr(exc, \"headers\", None) or getattr(exc, \"response\", None)\n    if hasattr(headers, \"get\"):\n        raw = headers.get(\"retry-after\")\n        if raw is not None:\n            try:\n                retry_after = float(raw)\n            except (TypeError, ValueError):\n                retry_after = None\n    return status, ptype, retry_after\n\nasync def call_with_retries(\n    fn,\n    *,\n    logical_id: str,\n    idempotency_key: str | None,\n    budget: RetryBudget,\n    max_attempts: int = 3,\n    base: float = 0.5,\n    cap: float = 20.0,\n    wall_clock_budget: float = 25.0,\n    allow_unkeyed_pre_send_retry: bool = True,\n    log=None,\n):\n    \"\"\"fn is called as fn(idempotency_key). An unkeyed write is never retried\n    unless the failure is provably pre-send.\"\"\"\n    if idempotency_key is None and not allow_unkeyed_pre_send_retry:\n        max_attempts = 1\n\n    log = log or (lambda a: None)\n    deadline = time.monotonic() + wall_clock_budget\n    last_exc = None\n\n    for attempt in range(max_attempts):\n        try:\n            result = await fn(idempotency_key)\n            budget.on_success()\n            return result\n        except Exception as exc:  # noqa: BLE001 - classification is explicit\n            last_exc = exc\n            status, ptype, retry_after = extract_error_fields(exc)\n            headers_received = status is not None\n            retryable, pre_send, reason = classify(status, ptype, exc, headers_received)\n\n            if not retryable:\n                raise\n            if attempt == max_attempts - 1:\n                raise\n            if idempotency_key is None and not pre_send:\n                # Unknown outcome with no key: retrying risks a duplicate effect.\n                raise\n            if not budget.grant():\n                raise RetryBudgetExhausted(reason) from exc\n\n            delay = retry_after if retry_after is not None else full_jitter(attempt, base, cap)\n            delay = min(delay, cap)\n            if time.monotonic() + delay &gt; deadline:\n                raise\n\n            log(\n                AttemptLog(\n                    logical_id=logical_id,\n                    attempt=attempt,\n                    reason=reason,\n                    status=status,\n                    provider_type=ptype,\n                    retry_after=retry_after,\n                    delay_ms=int(delay * 1000),\n                    outcome=\"retry\",\n                )\n            )\n            await asyncio.sleep(delay)\n\n    raise last_exc\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three properties make this correct. Classification happens before any retry decision, so a 429 carrying <code>insufficient_quota<\/code> never consumes budget and never adds load. The budget is checked before sleeping, so amplification is bounded even when everything is failing. And the unkeyed case is gated on <code>pre_send<\/code>, so a non-idempotent call retries only when the classifier can prove no bytes reached the provider. Pair it with <code>max_retries=0<\/code> on the SDK so exactly one retry layer exists.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What to log per attempt<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Log one record per attempt, not per logical request. The fields that earn their keep: logical request id, attempt number, idempotency key, provider, model, endpoint, error class, HTTP status, provider error type, the provider&#8217;s own request id from the <code>x-request-id<\/code> or <code>request-id<\/code> header, any <code>Retry-After<\/code>, computed delay, prompt and completion tokens, cost, duration, and outcome. That provider request id is your only handle in a billing dispute.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">From those records derive five metrics: retry ratio (attempts divided by logical operations), amplification (upstream calls divided by logical operations), retry success ratio, duplicate effects served from the dedupe table, and orphaned tokens, meaning billed tokens with no delivered response. These separate two situations that look identical in a success-rate dashboard. One flaky request: low retry ratio, amplification near 1.0x, high retry success ratio, normal latency. A masked outage: climbing retry ratio and amplification, falling retry success ratio, and rising p50 and p99 on requests that ultimately succeed, because backoff sleeps sit inside the request. Your error rate can look flat while the service is three times slower: that is the state worth alerting on. Metric design for this layer is covered in <a href=\"https:\/\/qoraapi.com\/blog\/llm-observability\/\">LLM observability<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The default retry policy I would ship<\/h2>\n\n\n\n<ul class=\"wp-block-list\"><li>Exactly one retry layer. Set SDK retries to zero and own the policy centrally, so amplification is your policy rather than the product of two.<\/li><li>Retry on connection refused, DNS failure, TLS failure, 408, 429 rate-limit, 500, 502, 503, 504, and provider overload types (529 <code>overloaded_error<\/code>, <code>ThrottlingException<\/code>, <code>RESOURCE_EXHAUSTED<\/code>, <code>UNAVAILABLE<\/code>).<\/li><li>Never retry 400, 403, 404, 413, 422, content-filter blocks, context-length overflows, or quota and billing errors. Allow one retry on 401, only after a credential refresh that changed the credential.<\/li><li>Three attempts for interactive calls, five for batch. Full jitter, base 500 ms, cap 20 s interactive and 60 s batch, with a 25 s wall-clock budget per logical operation including every sleep.<\/li><li>Honour <code>Retry-After<\/code> whenever present, clamped to the cap.<\/li><li>An idempotency key is mandatory for every request that can bill or cause a side effect. Without a key, at most one retry, and only for provably pre-send failures.<\/li><li>A retry budget token bucket of 100 tokens refilling at 0.1 per successful logical request, checked before every sleep.<\/li><li>One breaker per provider: 20-request minimum sample, open at 50% failures, 30 s cooldown with exponential backoff capped at 5 minutes, one cheap half-open probe.<\/li><li>One in-flight semaphore per provider, which retries must acquire through. Halve the limit on 429s, recover at roughly 10% per minute.<\/li><li>Never retry a partially consumed stream. Never hedge a non-idempotent call. Never let a model generate an idempotency key.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That policy is deliberately conservative about retrying and aggressive about classifying. Most teams I review have the ratio inverted: a generic retry decorator everywhere, no classification, no key. The result looks resilient in staging and doubles the inference bill during the first real incident.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Is a 500 always safe to retry?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. A 500 can arrive after the request was fully processed and the response failed on the way out, which makes it unknown-outcome rather than pre-send. Some gateways also return 500 wrapping an upstream 400 or a content-filter rejection. Treat 500 as retryable only when the body&#8217;s error type indicates a server-side fault, and require a key.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should the idempotency key be a UUID?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A UUID is fine for uniqueness but not sufficient. The key must be generated once per logical operation and persisted before the first send. A UUIDv4 generated inside the function that makes the HTTP call is regenerated on every retry, which makes it useless. Prefer UUIDv7 or ULID for sortability, and prefer derivation from a business event id.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I just retry the whole agent run instead of individual steps?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. An agent run is a sequence of steps, some with side effects, so retrying the run re-executes every completed step and duplicates each effect. Retry at the step level with a key derived from the run id and step index, and treat the run as a state machine that resumes from the last committed step. A step that cannot be made idempotent needs a compensating action, not a retry.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does my provider deduplicate retries automatically?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Assume not. Idempotency header support varies by provider, by endpoint, and often between streaming and non-streaming paths. Even where a header exists, verify it experimentally, because a silently ignored header looks exactly like a working one until you inspect the bill.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How long should the idempotency record live?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Longer than the worst-case window in which a client could replay: your maximum client timeout multiplied by your maximum attempts, plus queue delay, plus clock skew. A 30 s timeout with three attempts and 60 s of queueing gives roughly 150 s. Twenty-four hours of headroom is conventional because it also covers a restarted worker or a stuck batch job.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Retry correctness is a classification problem first and a timing problem second. Decide whether an attempt could have executed before deciding how long to wait, persist one idempotency key per logical operation before the first byte leaves, and cap the blast radius with a budget, a breaker and a per-provider semaphore. The backoff formula is the easy part; the classifier and the key are where duplicate generations, duplicate tool calls and double charges are actually prevented. If you would rather not maintain the dedupe table, the classification rules and the breaker state yourself, that is precisely the layer a gateway such as <a href=\"https:\/\/qoraapi.com\/\">qoraapi.com<\/a> can own, provided you understand that it bounds duplicate dispatch and not duplicate execution. Start with three attempts, full jitter, a mandatory key on every write, and an alert on amplification above 1.2.<\/p>\n\n\n\n\n<h3 class=\"wp-block-heading\">Related reading<\/h3>\n\n\n<ul class=\"wp-block-list\"><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-api-rate-limits-429-errors\/\">How to Handle AI API Rate Limits and 429 Errors<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-api-failover-multi-provider\/\">How to Build a Multi-Provider AI Failover Layer for 99.9% Uptime<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/async-ai-api-jobs-webhooks\/\">Async AI APIs: Job Queues, Webhooks, and Long-Running Tasks<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/reliable-ai-agents\/\">Building Reliable AI Agents: Guardrails, Retries, and Human-in-the-Loop<\/a><\/li><\/ul>\n\n","protected":false},"excerpt":{"rendered":"<p>A timed-out request may have succeeded and been billed. Learn which failures are retryable, how to apply full-jitter backoff with a retry budget, and how idempotency keys prevent duplicate generations.<\/p>\n","protected":false},"author":1,"featured_media":297,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[5,6,9,7],"class_list":["post-298","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai-api","tag-ai-api","tag-api-gateway","tag-developer-tools","tag-developers"],"_links":{"self":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/298","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/comments?post=298"}],"version-history":[{"count":1,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/298\/revisions"}],"predecessor-version":[{"id":308,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/298\/revisions\/308"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media\/297"}],"wp:attachment":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media?parent=298"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/categories?post=298"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/tags?post=298"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}