A call to an AI API fails. The model is fine, the network is fine, but your request came back with a status you did not plan for. What your code does next decides whether a user sees a calm retry or a stack trace.

The good news is that an OpenAI-compatible API gives you everything you need in two places: the HTTP status, and a machine-readable type inside the error body. Read both, and most of the decision makes itself.

The error body has a type, so do not parse the message

Every error comes back in the same shape, with a type you can branch on and a human message you should not:

{
  "error": {
    "code": null,
    "message": "Insufficient quota. Please upgrade your plan.",
    "param": null,
    "type": "insufficient_quota"
  }
}

The message is for logs and for you. The type is for your code, because it does not change when the wording does.

Sort every failure into retryable or terminal

There are only two real buckets, and the status tells you which one you are in.

Terminal. Retrying will not help, so fix the cause.

  • 400 invalid_request_error: the request itself is wrong. A malformed body or an invalid parameter. Retrying the identical request just burns quota, so surface a bug.
  • 401 invalid_request_error: the API key is missing or invalid. Check the Authorization header and the key itself.
  • 402 insufficient_quota: the account is out of quota or has an unpaid overage. This is a billing state, not a transient one, so tell the operator rather than the retry loop.

Retryable. The same request can succeed later.

  • 429 rate_limit_exceeded: you are sending faster than the plan allows. Wait, then retry.
  • 500 api_error: something failed on the provider side. Back off, then retry.

Anything else, including 5xx beyond 500 and a dropped connection, is also worth one bounded retry, because the failure is outside your request.

Retry only the retryable ones, with a ceiling

A retry loop without a ceiling is an outage you wrote yourself. Cap the attempts, back off between them, and add jitter so a fleet of clients does not all return at the same instant.

import time, random, httpx
from openai import OpenAI

client = OpenAI(base_url="https://webwright.ai/v1", api_key="www_v1_...")

RETRYABLE = {"rate_limit_exceeded", "api_error"}

def chat_with_retry(messages, max_attempts=4):
    for attempt in range(max_attempts):
        try:
            return client.chat.completions.create(
                model="wren-r1", messages=messages
            )
        except Exception as err:
            status = getattr(err, "status_code", None)
            etype = getattr(err, "type", None)
            retryable = status in (429, 500) or etype in RETRYABLE
            if not retryable or attempt == max_attempts - 1:
                raise
            retry_after = getattr(err, "response", None)
            wait = 2 ** attempt + random.uniform(0, 0.5)
            if retry_after is not None:
                header = retry_after.headers.get("retry-after")
                if header:
                    wait = max(wait, float(header))
            time.sleep(wait)

Two details matter. On a 429, honor the Retry-After header instead of guessing: it is the server telling you how long to wait. And keep the ceiling small. Four attempts with backoff is plenty; past that, the failure is not transient.

Make the retry safe before you make it fast

A retry that creates a second job, a second charge, or a second row is worse than the original failure. Before you retry a write, give the work an idempotency key and have your own downstream logic treat two calls with the same key as one.

import uuid

def transcribe(path):
    key = str(uuid.uuid4())  # stable per unit of work, not per attempt
    with open(path, "rb") as audio:
        return client.audio.transcriptions.create(
            model="whisper-large-v3-turbo",
            file=audio,
            extra_headers={"Idempotency-Key": key},
        )

Keep the key stable for the unit of work and reuse it across attempts, so a retry after a timeout resumes the same job instead of starting a new one.

Tell the user the truth, not the stack trace

A user-facing message should match the real cause, because the fix differs by cause.

  • Out of quota (402): say the account needs attention, and who can fix it. Do not invite a retry.
  • Bad key (401): say the connection is not authorized. That is an operator fix, not a user one.
  • Rate limited (429) or provider error (500): say you are retrying and will fall back if it keeps failing. Then actually do both.

Log the type, the status, and a request id. Never log the API key, and never paste the raw error body into a user-facing screen.

One path, both providers

Because the schema is the OpenAI error schema, the same classifier works when you add a second OpenAI-compatible provider behind the same client. You branch on type and status, not on a provider name, so a fallback is a base-URL change and nothing else.

Handle the failure once, in one place, and every call in your app inherits it.

Building with an AI API and deciding how it should fail? Tell us what you are shipping and we will walk the failure path with you. Book a consult using the link below.

The WebWright Team