PerdurancePerdurance

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.

StatuscodeWhat happenedWhat to do
400bad_requestThe request was malformedRead message; it names the problem
400perdurance_key_forwardedA header the provider would read is carrying a Perdurance keyUnset ANTHROPIC_AUTH_TOKEN and send the key in X-Perdurance-Key alone. See Claude Code and the Agent SDK
401unauthorisedNo key, or one that does not matchCheck the key; both "missing" and "wrong" answer the same way on purpose
401provider_credentialThe bearer token is an Anthropic credential, not a Perdurance keySend your Perdurance key instead. See Claude Code and the Agent SDK
401caller_credential_missingThe backend forwards whatever credential a caller brings, and this call brought noneSend one in Authorization or x-api-key, with your Perdurance key in X-Perdurance-Key
402insufficient_creditsThe tenancy has no credit left, on a deployment that metersAdd credit. Never returned where metering is off
402spend_limit_reachedA spend cap on the key or the namespace was reached, on a deployment that metersRaise the cap or wait for the window. Never returned where metering is off
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
413body_too_largeThe submission body is larger than 2 MiBSend less in one call. The ceiling is fixed at build time, not a deployment figure
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
422backend_host_refusedA backend was pointed at an address inside the deployment's own network, on a deployment that reaches only public onesPoint it at an address the internet routes to. See Administration
422passthrough_disabledA backend that forwards the caller's own credential, on a deployment not started with --credential-passthrough, or on the fire-and-forget routeSee Claude Code and the Agent SDK
502upstream_failedThe provider could not be reached, or refused in a way that was not an HTTP answermessage carries what went wrong. A refusal the provider did answer with is passed through instead
504held_too_longA synchronous answer gave up waitingThe request is still running. See below
508loop_detectedThe submission arrived already at the re-route ceilingSomething is calling Perdurance through Perdurance; message says how deep it got
500internalA fault on our sidemessage 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.

On this page