Errors and status codes
Every status this API returns, the stable code that comes with it, and what to do about each — including the 504 that does not mean your request failed.
Every error is one JSON object with the same shape, whatever produced it:
{
"error": {
"code": "held_too_long",
"message": "the request is still running as 01J8F2ZK9QX3M4NBVWT7; re-send the identical body to attach to it, or read it back by id"
}
}code is stable and meant to be branched on. message is meant to be read by a person and may
change.
The table
| Status | code | What happened | What to do |
|---|---|---|---|
400 | bad_request | The request was malformed | Read message; it names the problem |
401 | unauthorised | No key, or one that does not match | Check the key; both "missing" and "wrong" answer the same way on purpose |
403 | forbidden | The key is valid but holds no grant for that scope, or a rule refused the call | Widen the grant, or read message for which rule refused |
403 | password_change_required | The account still has a password it did not choose | Set a password before anything else |
403 | password_rejected | The current password offered did not match | — |
404 | not_found | No such resource, or not one this key can see | — |
409 | conflict | It already exists, or something changed underneath | Re-read and retry |
410 | record_expired | The request happened; its record has been swept | Don't retry the read. See below |
415 | unsupported_media_type | The body was not sent as application/json | Set the header |
422 | unprocessable_entity | Well-formed, but not something this service can act on | Most often a dialect mismatch — see below |
502 | upstream_failed | The provider call itself failed | message carries what the provider said |
504 | held_too_long | A synchronous answer gave up waiting | The request is still running. See below |
500 | internal | A fault on our side | message says nothing about the cause, deliberately |
Three that do not mean what they usually mean
410 record_expired is not 404
A 404 means the id was never yours. A 410 means it was, the request ran, and its record —
the body you sent, the answer, and the chunks — has been swept past the retention horizon its
namespace was given.
Everything you bill and report from is still there: the status, the model, the token counts, the
cost and the timings are on GET /requests, and the fee ledger outlives them both.
What is gone is the content, and retrying the read will not bring it back.
If you are seeing this on requests you still need, the horizon is a namespace setting — raise it in the console under Namespaces.
504 held_too_long is not a failure
A synchronous call holds the connection for at most the deployment's synchronous hold — five minutes by
default. When a generation outlasts that, the connection is let go with a 504.
The execution is not cancelled. It is still running, and its answer will be stored. The message carries the request id, and you have two ways back:
# attach to the same execution — no second provider call
curl "$PERDURANCE_URL/chat/completions" -H "Authorization: Bearer $PERDURANCE_KEY" \
-H 'Content-Type: application/json' -d "$IDENTICAL_BODY"
# or read it back by id
curl "$PERDURANCE_URL/requests/$ID" -H "Authorization: Bearer $PERDURANCE_KEY"Treating this as a failure and re-submitting a different body is the one mistake that costs real money: it starts a second provider call while the first is still running.
403 is two different things
Plain forbidden with the standard message means the key holds no grant for that scope — an
authorisation problem you fix by widening the grant.
forbidden with a message naming a rule means the opposite: the credential was sufficient, and
a rule declined what was asked. The message names which one, because a caller acting well
inside their own authority needs to know what they met.
superseded, mid-stream
A replayed stream can carry an event: failed with:
{
"status": "superseded",
"detail": "another worker took this request up and the chunks streamed so far were discarded with the attempt that wrote them; the request is still running and can be retrieved by its id"
}This is a recovery in progress: the worker that was producing your chunks died, another took the request up, and the partial output from the dead attempt was discarded rather than spliced together with the new one. The request is still running. Re-open the stream by id and you will get the new attempt's output from its beginning.
What is never in an error
A 500 says nothing about its cause. Provider credentials, prompt bodies and password hashes do
not appear in any error body, and a sign-in that failed cannot be used to learn which email
addresses exist — every rejected credential answers identically.

