Errors and status codes
Every status the submission and retrieval routes return, 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 Perdurance raises 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.
One class of error is not in this envelope at all. A refusal your provider answered with is passed through as the provider sent it, and has a section of its own below the table.
The table
These are the statuses the submission and retrieval routes on this site return. The administration and billing routes the console drives have a few of their own.
| Status | code | What happened | What to do |
|---|---|---|---|
400 | bad_request | The request was malformed | Read message; it names the problem |
400 | perdurance_key_forwarded | A header the provider would read is carrying a Perdurance key | Unset ANTHROPIC_AUTH_TOKEN and send the key in X-Perdurance-Key alone. See Claude Code and the Agent SDK |
401 | unauthorised | No key, or one that does not match | Check the key; both "missing" and "wrong" answer the same way on purpose |
401 | provider_credential | The bearer token is an Anthropic credential, not a Perdurance key | Send your Perdurance key instead. See Claude Code and the Agent SDK |
401 | caller_credential_missing | The backend forwards whatever credential a caller brings, and this call brought none | Send one in Authorization or x-api-key, with your Perdurance key in X-Perdurance-Key |
402 | insufficient_credits | The tenancy has no credit left, on a deployment that meters | Add credit. Never returned where metering is off |
402 | spend_limit_reached | A spend cap on the key or the namespace was reached, on a deployment that meters | Raise the cap or wait for the window. Never returned where metering is off |
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 |
413 | body_too_large | The submission body is larger than 2 MiB | Send less in one call. The ceiling is fixed at build time, not a deployment figure |
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 |
422 | backend_host_refused | A backend was pointed at an address inside the deployment's own network, on a deployment that reaches only public ones | Point it at an address the internet routes to. See Administration |
422 | passthrough_disabled | A backend that forwards the caller's own credential, on a deployment not started with --credential-passthrough, or on the fire-and-forget route | See Claude Code and the Agent SDK |
502 | upstream_failed | The provider could not be reached, or refused in a way that was not an HTTP answer | message carries what went wrong. A refusal the provider did answer with is passed through instead |
504 | held_too_long | A synchronous answer gave up waiting | The request is still running. See below |
508 | loop_detected | The submission arrived already at the re-route ceiling | Something is calling Perdurance through Perdurance; message says how deep it got |
500 | internal | A fault on our side | message says nothing about the cause, deliberately |
Provider errors are the provider's
When your provider answers a call with a refusal of its own, the two dialect routes,
POST /chat/completions and POST /messages, hand it back exactly as it arrived:
- the provider's status,
- the provider's body, byte for byte, as
application/json, retry-after, when the provider sent one.
No envelope, no code, nothing added. A vendor SDK pointed at Perdurance knows its own
provider's errors and has never heard of ours, and Claude Code goes further: it matches the
provider's error wording to retry a rejected capability and disable it for the rest of a
conversation. An envelope around the body breaks that recovery even when the status survives.
The rule holds for the first answer. A failure releases the idempotency claim, so an identical body re-sent after one runs again and is answered the same way.
A streaming call decides on its first item. It waits up to the synchronous hold for the
first chunk, and a provider that refuses before that chunk is answered with the provider's
status and body, rather than with a failure reported inside a 200 text/event-stream. Once the
first chunk has gone out the status line is spent, so a later failure arrives as event: failed.
Everything that is not a provider's answer keeps the envelope above. A transport failure is
502 upstream_failed, a held connection is 504 held_too_long, and every refusal before the
call reaches the provider is unchanged.
Reading a failed request back with GET /requests/{id} gives you the same three facts as
fields. error is the stored body, error_status is the provider's status, and retry_after
is what it asked for. The last two are absent when the failure was not an HTTP answer.
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.

