Two envelopes
The speech surfaces answer in the OpenAI error shape, because an OpenAI SDK parses that without a translation layer:/v1/audio, /v1/chat, /v1/models, /v1/realtime and
/v1/voices — including their authentication failures, and including request
validation, which comes back as a 400 rather than FastAPI’s 422.
Everything else — interact, story, sessions, memory, keys, organizations —
answers FastAPI’s shape:
code where there is one; fall back to the status. Both SDKs read
both envelopes and put the server’s own sentence on ApiError.message — see
SDKs.
HTTP status and code
The three
429s need three different responses, and telling them apart is the
whole reason they carry distinct codes: rate_limit_exceeded means slow down,
free_tier_daily_limit means come back tomorrow or pay, insufficient_quota
means pay. Only the first is worth a retry of the same request.
Three ways a model id fails
They are different mistakes and they answer differently, so do not collapse them into one handler.
Each message lists the ids that would have worked for that endpoint in this
deployment, which is usually the whole answer.
503 model_unavailable is a
deployment fact rather than a client mistake — nothing you can change in the
request fixes it.
/v1/realtime resolves models through the registry
directly rather than through that explaining layer, so all three cases arrive
there as the single 404 model_not_found — and on the socket, as a 1008
close carrying that message.WebSocket close codes
A live socket has no status line, so refusals arrive as close codes. Thereason carries the first 120 characters of the message.
1008 is the one that carries meaning. It is used for every connect-time
refusal: an unresolvable model, a failed credential, an exhausted balance, a
spent free day, and the per-minute rate limit.
A limit reached mid-call
The balance and the free-tier day are re-checked once a minute while a session is up, so a call can meet a limit it did not have at connect. Before the1008, the gateway sends one more eesi.session frame that says why:
limit.message. A client that ignores this frame tells the person
“connection lost”, which is both wrong and unactionable.
Note that resume.supported is false on it. Drop the handle rather than
redialling with it — the connect-time gate would refuse the resume for the same
reason, and a redial loop against a spent balance is the failure mode this
frame exists to prevent. Only insufficient_quota and free_tier_daily_limit
end an established session this way; a re-check that could not decide —
because the ledger or the ceiling store was unreachable — refuses a new session
but never cuts one that is already up.
What is worth retrying
Retry with backoff:429 rate_limit_exceeded, 502, 503, and a 1006
close. These are conditions that clear on their own.
Do not retry: anything 4xx that names a fix — a bad voice id, an unknown
model, a validation failure, an exhausted balance. Retrying a 429 insufficient_quota in a loop is how an outage becomes a rate-limit ban on top
of an outage.
Never retried for you: the SDKs retry a GET once on a connection failure
and nothing else, because a POST that failed on the wire may still have
reached the server. A retried voice clone is a second 100-credit charge; a
retried synthesis is a second bill. Retry a write only when you know it is safe
to repeat.
Reading an error in code
Next
Pricing and limits
The ceilings behind every
429.Connections that survive
Close codes, keepalives and resumption in full.
Authentication
What a
401 or a 403 on a key actually means.SDKs
ApiError, its fields, and the retry policy.