Which API Errors to Retry, and Which Never To

Most retry code is one if statement: if the status is not 2xx, sleep and try again. It survives contact with a healthy API and falls apart against a loaded one, because it treats three genuinely different situations as the same event - a request that was too fast, a request that was too parallel, and a file that was never going to convert no matter what.

File conversion APIs tend to collapse all of that into a single 429 and leave you guessing. Ours splits them deliberately, and the split is the whole point: the codes tell you which lever to pull.

The two 429s want opposite things from you

This is the distinction worth internalising, because getting it backwards produces a retry loop that cannot succeed:

  • 429 rate_limited - you exceeded requests per minute, or your monthly quota is gone. The lever is time. Wait, then continue.
  • 429 concurrency_limited - too many conversions are running at the same instant on your account. The lever is parallelism. Waiting does nothing if you then launch the same twenty requests together again.

Concurrency is a much smaller number than the per-minute rate - a handful of simultaneous jobs, depending on plan, against dozens or hundreds of requests per minute. That gap is why the second error surprises people: you can be comfortably inside your rate limit and still over your concurrency ceiling, simply because everything is in flight at once.

The response to concurrency_limited is therefore not a longer sleep but a smaller worker pool. If you see it repeatedly, your pool is sized wrong; lower the number of in-flight requests to match your plan and the error stops occurring rather than being retried around. Current per-plan concurrency figures are in the limits table.

Cap Retry-After, do not obey it blindly

Honouring Retry-After is correct advice that has a sharp edge on this API, and it is the sort of thing you discover at 3am.

Both flavours of rate limiting send Retry-After, but they send wildly different magnitudes. Trip the per-minute rate and it is a handful of seconds. Exhaust your monthly quota and the honest answer to "when can I retry" is when the quota resets - so that is what the header says, and it can be days.

A loop that sleeps for the raw value is indistinguishable from a hung process, and it will not wake up inside your deployment window. Cap it:

MAX_SLEEP = 60  # seconds

wait = int(resp.headers.get("Retry-After", 5))
if wait > MAX_SLEEP:
    # Not a hiccup - this is a quota wall. Stop and alert;
    # no amount of waiting inside this process will clear it.
    raise QuotaExhausted(retry_after_seconds=wait)
sleep(wait)

The rule of thumb: a Retry-After longer than your job's own tolerance is not a backoff instruction, it is a stop-and-escalate signal. Treat it as one.

The errors that are pointless to retry

The other half of a good retry policy is knowing what to give up on immediately. These describe the request or the file, so the same bytes will produce the same answer forever, and retrying only spends rate limit you could have used on files that would have worked:

  • 400 bad_request - malformed request, or an unreadable input. Fix the caller.
  • 401 unauthorized - missing or invalid key. Retrying with the same key is guaranteed to fail.
  • 411 length_required - no Content-Length. Size limits are checked before the body is read, so a declared length is mandatory. This is a client configuration bug, usually chunked transfer encoding, and it will not resolve itself.
  • 413 file_too_large - over your plan's per-request cap. Do not retry; either route the file to chunked upload or reject it upstream.
  • 415 unsupported_type - the file is not a valid input for that tool. Frequently a mislabelled extension rather than a broken file.
  • 422 conversion_failed - the file was read but could not be converted. Genuinely damaged, password-protected, or empty.

The last three are worth separating in your own reporting, because they are the ones that tell you something about your data rather than your code. A run where 4% of files come back 415 usually means an upstream process is writing the wrong extensions, and no retry policy will fix that.

Send all six to a dead-letter list with the file reference and the error code. That list is the useful output of a bulk job - much more so than a retry counter.

The 5xx pair, and why both are safe

Two server-side codes, both retryable, but they mean different things:

  • 500 server_error - an unexpected fault. Retry with normal backoff.
  • 503 server_busy - the service is at capacity right now. Also retryable, but this is a load signal rather than a fault, so back off harder and longer. Hammering a busy service is how you turn a brief spike into a long one.

Retrying either is safe, and the reason is structural rather than a promise: a conversion creates no server-side state. There is no job record, no file id, no queue entry - the file is converted in memory and the response is the result. So a retry cannot duplicate a job, cannot resume into a corrupted half-state, and cannot leave an orphan behind. Every request is naturally idempotent, which is a nicer property to build on than it sounds.

It also means the response is the only copy. Write it to your own storage as it arrives; there is no endpoint to fetch a previous result from, because nothing was kept.

A policy worth copying

Putting the classification together:

StatusAction
429 rate_limitedSleep (capped), retry. Escalate if Retry-After is huge.
429 concurrency_limitedShrink the worker pool, then retry.
503 server_busyRetry with aggressive backoff and jitter.
500 server_errorRetry with normal backoff, limited attempts.
400 401 411 413 415 422Never retry. Dead-letter with the reason.

Two details that matter more than the table. Add jitter to every backoff - without it, a pool of workers that all got throttled at the same moment will retry in the same moment, indefinitely. And cap total attempts per file rather than only the delay, so one pathological input cannot occupy a worker forever.

Finally, remember that a batch request can be partially successful with a 200 status. If your error handling only looks at status codes, those silent partial failures never reach your retry logic at all - the bulk conversion guide covers detecting them from the response headers.

Full status reference is in the API docs.

Developer resources:
Error reference Rate limits Plans & pricing