Skip to content
Search documentation titles, headings, and guides
API v1Updated 2026-09-21

Error Handling

The complete error-code table, the two native error envelope formats, and safe retry guidance.

Errors are returned as JSON with a stable code you can branch on. The envelope format depends on the endpoint: OpenAI-compatible endpoints use one shape, the Anthropic-compatible endpoint uses another.

Error formats#

/v1/chat/completions and /v1/responses return the OpenAI-shaped envelope:

OpenAI-shaped error (chat completions & responses)
{
  "error": {
    "message": "Insufficient balance. Top up PAYG credit or buy a Token Plan for this model.",
    "type": "billing_error",
    "code": "insufficient_balance",
    "request_id": "req_..."
  }
}

/v1/messages returns the Anthropic-shaped envelope:

Anthropic-shaped error (messages)
{
  "type": "error",
  "error": {
    "type": "billing_error",
    "code": "insufficient_balance",
    "message": "Insufficient balance. Top up PAYG credit or buy a Token Plan for this model."
  },
  "request_id": "req_..."
}

Error codes#

Complete error-code reference
StatusCodeTypeMeaning
400invalid_requestinvalid_request_errorMalformed or invalid request body or parameters. Also returned when the upstream rejects a request with 400.
401invalid_api_keyauthentication_errorMissing key, wrong key, two headers with different values, or a blocked key. The response is identical in all cases.
402insufficient_balancebilling_errorYour Token Plan and PAYG balance cannot cover the request. Top up before retrying.
404model_not_foundnot_found_errorThe model identifier does not exist or is hidden.
413request_too_largeinvalid_requestThe request body exceeds the size limit.
429technical_limitrate_limit_errorPassthrough of an upstream 429 — not a zrouter rate limit. zrouter applies no per-key limit; this signals the upstream is rate-limited.
500internal_errorinternal_errorAn unexpected internal error.
502upstream_errorupstream_errorThe upstream provider failed to process the request.
503overloadedserver_error / overloaded_errorCapacity limit reached (in-flight shed). Retry later. Type is server_error on OpenAI endpoints and overloaded_error on /v1/messages.
503circuit_openupstream_errorCircuit breaker open — upstream temporarily unavailable. A Retry-After header is set.

Retry best practices#

Transient failures — 429, 5xx, 502, 503 — are safe to retry. Follow these rules to avoid double-charging or amplifying load:

  • Exponential backoff with jitter on 429, 5xx, 502, and 503 — never a tight fixed-interval loop.
  • Retry only before the first byte. Once a stream has started, do not retry mid-stream.
  • Never retry 402. Top up your balance first; retrying will not help.
  • Cap retries at 2–3 attempts for a persistent outage.
  • Non-streaming timeout is 120s. Set your client timeout slightly below it so you see a clean error.
  • Settlement is idempotent and debit-after. You are charged only after inference succeeds — failed requests are never billed, so a safe retry costs nothing until it succeeds.

Handling errors in code#

Branch on the stable code field — never parse the human-readable message. Both SDK families expose the HTTP status and the error code, so one pattern covers every endpoint:

import random
import time

from openai import OpenAI, APIStatusError

client = OpenAI(
    api_key="YOUR_API_KEY",  # zr_... key from the dashboard
    base_url="https://api.zrouter.dev/v1",
)

RETRYABLE = {429, 500, 502, 503}


def chat(prompt: str, model: str = "<public-name>") -> str:
    for attempt in range(3):
        try:
            resp = client.chat.completions.create(
                model=model,
                messages=[{"role": "user", "content": prompt}],
            )
            return resp.choices[0].message.content or ""
        except APIStatusError as err:
            code = err.code or ""  # e.g. "insufficient_balance"
            if err.status_code == 402 and code == "insufficient_balance":
                raise  # never retry: top up PAYG or buy a Token Plan first
            if err.status_code in RETRYABLE and attempt < 2:
                # exponential backoff with jitter, capped at 3 attempts
                time.sleep(min(2 ** attempt, 8) + random.random())
                continue
            raise
    raise RuntimeError("unreachable")