API errors: codes, messages, and what to do
Every error is returned in the OpenAI envelope with an appropriate HTTP status - compatible with OpenAI SDK error handling. Below, one section per code: the literal message, the cause, the checks, and whether a retry is worth anything.
Error shape
An error response is always JSON. If the body is HTML, it did not come from our API but from a filter or proxy in between; that case is covered below.
Branch on error.code rather than the status - it is more precise: one 402 covers both an empty balance and a key that hit its cap.
Every status at a glance
400 - invalid_request_error
The request body failed validation before any model saw it: a required field is missing, a value is out of range, or the requested mode is not available for that model. The response’s param field names the culprit.
What you see
What to check
- Read error.param - it points at the exact field rather than "somewhere in the body".
- Check that messages is a non-empty array and model is a catalog id.
- For images and video, verify the model’s modes: image editing and image-to-video are not universal.
- If type is content_policy_violation the shape is fine - moderation rejected the content, so rephrase the prompt.
Retry or not
401 - authentication_error
The key was not sent, does not exist, or has been revoked. Keys are stored as SHA-256 hashes, so an existing key cannot be looked up - only reissued.
What you see
What to check
- The header must be Authorization: Bearer ar-… (x-api-key is accepted too).
- Check the key was not truncated on copy and carries no line breaks.
- Make sure the key is not revoked in the dashboard: a revoked key answers with the same 401.
- Check the base URL: https://api.altrouter.ai/v1 - an ar-… key will not work against another gateway.
Retry or not
402 - insufficient_quota
There is not enough money to reserve the cost of the request - either on the organization balance, or within a particular key’s spending cap. Two different codes, two different fixes.
What you see
What to check
- Look at error.code: insufficient_quota is the balance, spend_limit_exceeded is the key’s cap.
- For insufficient_quota, top up in the dashboard.
- For spend_limit_exceeded the balance may be full: raise or remove the key’s cap. Windows are day, week, month and all-time.
- Remember holds: an estimated cost is reserved up front, so available funds sit below the balance by the size of open holds.
Retry or not
403 - permission_error
The key is genuine but is reaching for a surface outside its scopes. The second, rarer case: the client address is on the block list - such a request is refused before routing and before authentication.
What you see
What to check
- Compare the key’s scopes in the dashboard with the surface you are calling: chat, images, videos.
- A key with no scope list has access to everything; a scoped key only to what is listed.
- If you need another surface, issue a new key with that scope rather than editing the request.
- The access_denied code means an address-level block, not a key problem.
Retry or not
404 - not_found_error
The requested model is not in the catalog, or it belongs to another surface - a video model asked for at /v1/chat/completions, say. The same status answers an unknown path.
What you see
What to check
- Take the id verbatim from GET /v1/models or the catalog - ids are slugs like gpt-5.4 or gemini-2.5-flash.
- Check the surface: chat models go to /v1/chat/completions, images to /v1/images/generations, video to /v1/videos.
- For unknown_endpoint, check the path and the base URL: https://api.altrouter.ai/v1.
Retry or not
429 - rate_limit_error: rate limit exceeded
The key went past its requests per minute. The default is 600 rpm per key, though an individual key can carry its own. The window is fixed at 60 seconds and is counted per key, not per organization.
What you see
What to check
- Read the Retry-After header - it carries the seconds left in the window.
- Watch x-ratelimit-limit and x-ratelimit-remaining on every response: the headroom is visible before you run out.
- Spread peak load across several keys, or ask for a higher rpm on the key.
- Do not add parallelism in response to a 429 - it only burns the window faster.
Retry or not
500, 502, 503, 504 - failures on our side and the provider’s
500 is an internal gateway error; its text is always the same and the detail stays in our logs rather than going to the client. 502 means the provider answered with an error. 503 means no healthy route was found for the model - the router had already walked its failover providers before returning it. 504 means the request did not finish inside its deadline.
What you see
What to check
- Retry with exponential backoff - this is the one category where a retry genuinely changes the outcome.
- If a 503 persists, try another model: the status is about that model’s routes, not about the whole gateway.
- On a 504, shorten the expected answer (max_tokens) or switch to streaming: SSE delivers the first tokens without waiting for the end.
- For /v1/images and /v1/videos, retry with an Idempotency-Key header so you do not create a second generation and a second charge.
Retry or not
Which error codes are safe to retry
The short answer: retry 429 and the 5xx family. The 4xx family describes the state of your request, key or account - and that state will not change on its own.
Forbidden: access denied by security policy - and other errors that are not ours
Some of the messages people bring to support are not returned by our API at all. They are easy to tell apart: the body is an HTML page rather than JSON, with no error.type and no error.code in it.
That is a site security filter (a WAF, most often Cloudflare) refusing a connection by client address or country. The request never reaches an API, so the key, the balance and the body make no difference, and a retry changes nothing. There are two ways out: a different network route, or a gateway that talks to the models itself. altrouter requests go to api.altrouter.ai and we are the ones calling OpenAI, Anthropic and Google.
Codes you only see in the logs
A 499 status with code client_closed is not a service failure: it marks a request whose connection the client itself dropped - a cancelled stream, for instance. The code stream_error marks a stream that broke after it started: the 200 headers had already gone out, so the failure has no HTTP status of its own and shows up in the request log instead.