PerdurancePerdurance

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

StatuscodeWhat happenedWhat to do
400bad_requestThe request was malformedRead message; it names the problem
401unauthorisedNo key, or one that does not matchCheck the key; both "missing" and "wrong" answer the same way on purpose
403forbiddenThe key is valid but holds no grant for that scope, or a rule refused the callWiden the grant, or read message for which rule refused
403password_change_requiredThe account still has a password it did not chooseSet a password before anything else
403password_rejectedThe current password offered did not match
404not_foundNo such resource, or not one this key can see
409conflictIt already exists, or something changed underneathRe-read and retry
410record_expiredThe request happened; its record has been sweptDon't retry the read. See below
415unsupported_media_typeThe body was not sent as application/jsonSet the header
422unprocessable_entityWell-formed, but not something this service can act onMost often a dialect mismatch — see below
502upstream_failedThe provider call itself failedmessage carries what the provider said
504held_too_longA synchronous answer gave up waitingThe request is still running. See below
500internalA fault on our sidemessage 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.

On this page