A failed turn is a 200 OK
A streamed turn that fails answers 200. It has to — the failure is a frame inside a body that had already started. Which means it appears in no access log, no 5xx count, and no HTTP-level alert you have built.

That is the correct shape and it has a cost. A client retrying into a provider that cannot answer produces a failure every few minutes and looks, from outside, like silence.
Two reads answer it:
curl -s "https://oberik.com/api/projects/$PROJECT_ID/turns" -H "X-API-Key: $KEY"
# {"window_hours":24,"total":234,"errored":234,"consecutive_errors":234,
# "last_error":"the upstream endpoint answered 502 Bad Gateway","last_success_at":null}consecutive_errors is the number to alert on. It counts back from the most recent turn and a success resets it, so it separates a client retrying carefully from a client stuck in a loop. The control plane says the same thing without you asking: GET /readiness grows a turns step once the streak passes a handful.
Neither refuses anything or asks you to back off. What is failing is your own provider or configuration, the first failure already said which, and retrying into it returns the same answer.
The status codes, and the two we split
| Status | Meaning | What to do |
|---|---|---|
400 | malformed request, a bad attachment, or the provider rejecting it | fix the call; detail names it |
401 | missing, invalid or expired token | mint a fresh one |
403 | capability or origin denied, or an action you may not take on something you can see | not allowed — don't retry |
404 | not found, or not visible to this subject | treat as not found |
409 | a name taken, an upload out of order, a turn already running, or no usable provider | reconcile, then retry |
422 | invalid body, or an ingest that failed on the document | detail is an array |
429 | rate limit or usage cap | back off |
500 | our bug, or a backend we depend on | retry once |
502 | the provider failed with an error of its own | retry with backoff |
504 | the provider did not answer in time | retry with backoff |
504, not 502, when nothing answered. A provider that never replies and a provider that replies badly are different problems with different fixes, and they used to share a number. If your model endpoint sits behind a CDN, a 504 here is the same event your CDN logs as 524 — your origin exceeded its limit — and the detail says so: the request to the upstream endpoint timed out after 125.4s. There is no status code in that sentence, because none was ever sent.
An attachment you got wrong is a 400, not a 502. Three shapes used to answer 502: a host that does not resolve, a data: URI cut short, and a URL that resolves to something that is not an image. Which told every client following the table above to retry a request that cannot succeed, forever.
The 404 that is really a 403
Reading another subject's document or session is 404, in the same words a made-up id gets. Not 403, which would confirm the id was real.
403 is kept for the case where it tells you nothing you did not already know: you can see the thing and may not do this to it, or the capability, origin or model allow-list refused the call outright.
detail is a string except when it is an array
On a 422 — and only there — detail is an array of {loc, msg}, because that is the one status where the useful content is per-field. So `${e.detail}` prints [object Object] exactly where you most wanted to read it.
e.message is always a readable summary. Use that for logs, and reach into detail when you want to attach errors to form fields.
Rate limits used to be enforced by waiting
This one is worth telling because the symptom was so strange.
tpmLimit and rpmLimit bound how fast a project may call its models. They used to be enforced by sleeping rather than refusing — the proxy answered Retry-After: 60 and our own HTTP client slept through it, twice.
So a project with rpmLimit: 2 sending five requests saw 5.6s, 5.9s, 64.1s, 66.0s and 125.6s. And no 429 at all.
That outlived most client timeouts, including our own SDK's, so the platform was holding requests its caller had already abandoned, and nothing anywhere named the delay. It is the ordinary 429 path now, with the reset time in the message:
429 rate limit reached on rpm — this is transient, so retry shortly (resets at 12:01 UTC).
Raise the per-minute limits under LLM & limits if it keeps happening.Back off and retry. Unlike a spend cap, this one clears by itself — budget_exceeded is a cap you set, so retrying will not help until the window resets or you raise it.
Terminal, or dropped?
AgentStreamError.terminal says which. true means the server sent an error frame and closed: status, code and the sentence are all populated, the SDK does not reconnect, and retrying gets the same answer. false means the stream stopped without saying anything, which is the case reconnection is for.
It is a property rather than something you parse out of the message, and that is not cosmetic. The SDK used to decide terminality by testing whether the message started with "server: ". That prefix was removed for being noise in front of the first word a customer reads — and every server refusal silently became a "dropped connection". Ten reconnects over 136 seconds against a 405, ending in status: null.
A streamed failure carries the same status a blocking POST /chat would have given, so one retry policy covers both paths. A rotated provider key is 409 either way, a spend cap is 429 either way, a provider timeout is 502 either way. Treat a frame without a status as a failure you cannot classify, rather than as a retryable one.
When the provider goes away mid-conversation
Rotating a credential out from under a live conversation is routine. It used to answer with the proxy's own words: User not found.
A customer reads that as their end-user and goes looking at the subject, the token, the mint call, and per-user isolation — all correct, none of them the thing that changed. It is a 409 now, naming the cause and the fix, with code: "no_provider" on the streaming path.
Do not retry it. And the conversation is not damaged: the failed turn persisted nothing, and a turn on the same session works the moment a provider exists again, with its history intact.
Log the run id
Every streamed run has one, on X-Run-Id and the run event. A failure carries it, along with the session id, on both paths — so a report of one is answerable afterwards rather than only at the moment it happened.
console.error(e.status, e.message, { runId: e.runId, sessionId: e.sessionId });
if (e.sessionId) await ai.chat.sessions.messages(e.sessionId);Whatever the turn managed to write is still readable. Log it next to your own request id and a support question becomes one lookup instead of an investigation.
