Skip to navigation

Retries and backoff

Which failures clear on their own, and how to wait without making things worse.

Retry these

a transport failure or timeout with no response at all
408, 429
500, 502, 503, 504
409 IDEMPOTENCY_IN_PROGRESS

Do not retry these unchanged

400 the request is malformed
401 the key is missing, wrong or no longer usable
402 there are no credits
403 the key lacks this operation's scope
404 it is not there
409 IDEMPOTENCY_KEY_REUSED -- you changed the request under an existing key
429 QUOTA_EXCEEDED -- the daily quota resets on its own clock, not after Retry-After
410 the input asset expired -- upload again, then submit under a new key
413 the upload is too large
415 the upload is not a supported image type
422 the image cannot be used

Retrying any of those repeats a request that is wrong, at cost, until a quota stops you. IDEMPOTENCY_KEY_REUSED is the one most likely to be retried by accident: it looks like a conflict that might clear, and it never will, because the conflict is between two things you sent.

Every error response also says so itself: error.retryable is true exactly for the codes above that may be retried, and error.doc_url links the code’s row in the error reference. Both are fields of the JSON error envelope, so they work over plain HTTP today.

The SDKs expose them on ApiError as retryable and doc_url (Python) or docUrl (TypeScript).

Keep the idempotency key across retries

The retried call must carry the same Idempotency-Key as the attempt that failed. A retry with a fresh key is a second submission, and it will be analysed and billed. See Idempotency.

Honour Retry-After, then back off

When Retry-After is present, use it. It is not a suggestion — it is the server telling you when it will be able to answer, and ignoring it converts a brief saturation into a sustained one.

A 429 can also carry RateLimit-* headers naming each limit and when it resets; see Rate limits.

When it is absent, use capped exponential backoff with jitter:

import random, time
def backoff(attempt, cap=30.0, base=0.5):
window = min(cap, base * 2 ** attempt)
return random.uniform(0, window) # full jitter

The jitter matters more than the exponent. Without it, every client that failed at the same moment retries at the same moment, and the retry storm is the outage.

Two deadlines, not one

PER_REQUEST_TIMEOUT = 10 # seconds on a single HTTP call
OPERATION_DEADLINE = 300 # seconds for the whole submit-poll-fetch sequence

A per-request timeout alone gives you an unbounded loop of individually quick calls. An overall deadline alone lets one hung socket consume the whole budget. You want both, and you want the overall deadline to be shorter than whatever is waiting on you upstream.

When the overall deadline expires, stop waiting — but remember that stopping does not cancel the job. Persist the job id, return control, and pick the result up later. The analysis will finish.

Polling retries keep the ETag

A retried poll should still send If-None-Match with the last ETag you held. A 304 is cheaper for both sides, and the retry does not invalidate what you cached.

What a correct loop looks like

submit with a stable Idempotency-Key
|-- 2xx -> persist the job id
|-- 429/503/5xx/transport -> wait (Retry-After or backoff), retry with the same key
\-- 4xx (other) -> stop, report error.code
poll, waiting Retry-After between attempts, sending If-None-Match
|-- 304 -> keep waiting
|-- 200 queued|running -> keep waiting
|-- 200 succeeded -> fetch the result assets before expires_at
|-- 200 failed -> handle job.error.code, do not resubmit blindly
\-- 503 -> keep waiting; this is not expiry
until the operation deadline, then persist and resume later