Errors and retries
Read the error envelope, look up every error code the API defines, and decide which failures to retry and how long to wait.
A failed request returns a non-2xx status and a JSON error body. Read the HTTP
status first, then branch on the body's code: the code is stable, while the
wording of the message can change.
The error envelope
{
"code": "invalid_request",
"error": "invalid JSON body",
"request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}| Field | Meaning |
|---|---|
code | A stable, machine-readable error code. Branch on this. |
error | A human-readable message. Log it; do not parse it. |
request_id | The id of the failed call, equal to the response's X-Request-Id header. Log it with every failure; see Request IDs and metadata. |
Some codes add fields next to these three. Each is optional, and present only on the codes listed:
| Field | Sent on |
|---|---|
tier | The tier a model failure or context check was about: upstream_error, upstream_overloaded, upstream_timeout, model_unavailable, and server_shutting_down on chat, and context_overflow. |
reason | Why: rate or concurrency on rate_limited from chat; why a document's text could not be read on file_not_text. |
retry_after | The Retry-After value in seconds: rate_limited, limits_unavailable, credits_unavailable, headless_concurrency_limit. |
reset_at | When the refusal clears (RFC 3339): budget_exhausted, key_spend_cap_exceeded, workspace_spend_cap_exceeded, credits_exhausted. null on model_not_allowed, since waiting never clears an allowlist. |
model, state, window, mira_available | A spend or budget refusal: budget_exhausted, key_spend_cap_exceeded, workspace_spend_cap_exceeded, model_not_allowed. window is monthly or burst; mira_available says whether the same request on mira would be served now. |
balance, currency | A headless key's remaining credit, on credits_exhausted. |
dimension | The check that faulted on limits_unavailable: rate or budget. |
required_scope, allowed_scopes | The family the key would need and the families it has, on key_scope_forbidden. |
required_version | The Cruxy-Version a request field needs pinned, on invalid_request. |
hazard_codes | The blocking classifier codes behind moderation_rejected. |
tool, bound, limit | The function and the cap it broke, on invalid_tool_schema. bound and limit also name the cap on file_too_large, and limit is the context window on context_overflow. |
file_id, filename, content_type, tokens | The refused file, on file_not_found, file_not_text, file_too_large, and image_not_supported. |
vision_tiers | The tiers that read images, heaviest first, on image_not_supported. |
measured_bytes, limit_bytes | The size and the cap on input_not_countable. |
Ignore fields you do not recognize.
Three surfaces use a different shape:
POST /v1/chat/completionsuses the OpenAI envelope,{"error": {"message", "type", "param", "code"}}. Itscodeis the same Cruxy code listed here;typeis a coarse category derived from the status (invalid_request_error,authentication_error,permission_error,not_found_error,rate_limit_error, orserver_error);paramis alwaysnull.- Device login answers a pending or refused poll with an OAuth-style body; see Get your API key.
- A
POST /api/v2/chatstream that fails after it opens ends with anerrorevent carryingcode,error, andrequest_id.
Handle errors and retry safely
This request sends an Idempotency-Key, so once it succeeds, running it again
with the same key replays the stored response instead of calling the model and
billing again. The gateway stores only a success, so a retry after an error
runs the request again under the same key. The TypeScript version retries the
codes the API marks retryable, waiting Retry-After when it is sent, and
retries a request that got no response with the same key:
IDEMPOTENCY_KEY=$(uuidgen)
# -i prints the status line and headers, including Cruxy-Version and the
# X-RateLimit-* headers. Run it twice: once the first call succeeds, the
# second replays it.
curl -i https://api.cruxy.ai/api/v1/chat \
-H "Authorization: Bearer $CRUXY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{
"model": "vaani",
"stream": false,
"messages": [
{ "role": "user", "content": "Hello" }
]
}'class CruxyError extends Error {
constructor(
readonly status: number,
readonly code: string,
readonly requestId: string,
message: string,
) {
super(`${status} ${code}: ${message} (request_id ${requestId})`)
}
}
// Your retry budget and timeout. Tune them for your workload; they are not API limits.
// TIMEOUT_MS sits above the longest tier deadline (90 s), so a slow model
// reaches you as 504 upstream_timeout rather than as your own abort.
const MAX_ATTEMPTS = 4
const BASE_DELAY_MS = 500
const TIMEOUT_MS = 120_000
// Transient failures the API marks retryable. upstream_timeout is left out:
// the gateway already made its one attempt, and the same request usually
// times out again.
const RETRYABLE = new Set([
'rate_limited',
'credits_unavailable',
'headless_concurrency_limit',
'limits_unavailable',
'upstream_overloaded',
'model_unavailable',
'all_models_unavailable',
'server_shutting_down',
'internal_error',
])
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
// Exponential backoff with full jitter.
const backoff = (attempt: number) => Math.random() * BASE_DELAY_MS * 2 ** attempt
async function chat(body: Record<string, unknown>) {
// One key per logical request, reused on every retry of it.
const idempotencyKey = crypto.randomUUID()
for (let attempt = 1; ; attempt++) {
let res: Response
try {
res = await fetch('https://api.cruxy.ai/api/v1/chat', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CRUXY_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ ...body, stream: false }),
signal: AbortSignal.timeout(TIMEOUT_MS),
})
} catch (err) {
// No response: the call may or may not have run. If it succeeded, the
// same key replays its stored response rather than running and billing
// it a second time; if it failed, the same key runs it again.
if (attempt >= MAX_ATTEMPTS) throw err
await sleep(backoff(attempt))
continue
}
if (res.ok) return res.json()
const { code, error, request_id } = await res.json()
// An error is never stored, so the same key runs the request again.
if (RETRYABLE.has(code) && attempt < MAX_ATTEMPTS) {
const retryAfter = Number(res.headers.get('Retry-After'))
await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt))
continue
}
throw new CruxyError(res.status, code, request_id, error)
}
}
const completion = await chat({
model: 'vaani',
messages: [{ role: 'user', content: 'Hello' }],
})
console.log(completion.message.content)Which failures to retry
| Failure | Retry? | How |
|---|---|---|
429 rate_limited, credits_unavailable, headless_concurrency_limit | Yes | Wait Retry-After seconds, then retry. |
503 upstream_overloaded, model_unavailable, all_models_unavailable, server_shutting_down, limits_unavailable | Yes | Back off, honouring Retry-After where it is sent. The gateway already retried the model before it answered upstream_overloaded or model_unavailable. |
500 internal_error | Yes | Back off. A write may have run, so retry with the same Idempotency-Key where the route takes one. |
504 upstream_timeout | Only if changed | The same request usually times out again. Stream it, or lower max_tokens or reasoning_effort; see Timeouts. |
429 budget_exhausted, key_spend_cap_exceeded, workspace_spend_cap_exceeded, credits_exhausted | Not now | The allowance clears only at reset_at, or when the cap changes. Retry-After counts down to that moment. |
422 structured_output_unsatisfied | Yes | It may pass on a retry. The attempt was billed; a retry with the same key and body is not billed again. |
409 idempotency_conflict | Yes, adjusted | Resend the original body to replay the stored response, or send the new body under a new key. |
| No response at all: a dropped connection or your own timeout | Yes | Retry with the same Idempotency-Key. |
A stream that ended before data: [DONE] | Yes | Send the request again; see Streaming. |
502 upstream_error from a model call | No | The model provider rejected the request, and it fails the same way again. From native sign-in it is retryable. |
503 count_unavailable | No | A fact of the deployment; it does not clear. |
| Every other code | No | It describes the request or the credential. Fix the request, the key, or the target first. |
Rate limits and budgets explains the difference between a rate limit and a budget, and how to read your remaining headroom.
Timeouts
With stream: false, the gateway makes one attempt at the model, bounded by
the tier's deadline:
| Tier | Deadline |
|---|---|
kavi | 90 seconds |
vaani | 90 seconds |
mira | 45 seconds |
An attempt that runs past the deadline is cancelled and answered 504
upstream_timeout, and nothing is billed. The gateway does not retry a
timeout: a turn that needed longer needs longer again. Send stream: true
instead, since a stream may run for up to five minutes, or ask for less with a
lower max_tokens or reasoning_effort. Reliability
collects the deadlines, retries, and stream checks in one place.
Other upstream failures, such as an overloaded or failing model or a dropped
connection, are retried inside the gateway a bounded number of times, with
backoff, before it answers 503 upstream_overloaded or model_unavailable.
On stream: true that happens only while the stream opens, never after a byte
has reached you. A call never moves to another tier once it is sent:
model: "auto" picks a healthy tier before the call, and an explicit tier that
keeps failing is refused at once with 503 model_unavailable while it cools
down.
Back off
- Honour
Retry-After. Every429carries it, and so does a503limits_unavailable. Onrate_limited,X-RateLimit-Resetgives the same moment as an absolute Unix time in seconds. On a budget or spend-cap refusal,Retry-Aftercounts down toreset_atinstead, while theX-RateLimit-*headers still describe the per-minute request window. - Otherwise back off exponentially, with jitter. Double the wait after each failed attempt and randomize it, so many clients that failed together do not retry together.
- Cap the attempts and the total wait. A retry budget keeps a persistent failure from turning into a stuck request.
- Log
request_idfor every failure, retried or not.
Make retries idempotent
Idempotency-Key makes a retry safe. The first call with a key is served, and
when it succeeds its response is stored for 24 hours. A later or concurrent
call with the same key and the same body replays that response byte for byte,
without calling the model again and without billing again; a concurrent
duplicate waits for the first call to finish.
Only a success is stored. An error, a 429 or any 5xx included, releases the
key, so a retry with the same key and body runs the request again. Reusing the
key is the intended way to retry a 429 or a 5xx.
- Scope: a key belongs to your API key and to the endpoint. On a conversation append, it belongs to the conversation.
- Conflicts: the same key with a different body is
409idempotency_conflict. - Large responses: a response over 256 KiB is served but not stored, so a retry runs again. It is still billed once.
- Length: keys are at most 255 characters.
These endpoints accept it:
POST /api/v1/chat,POST /api/v2/chat, andPOST /v1/chat/completionsPOST /api/v1/embeddings,POST /api/v1/rerank, andPOST /api/v1/moderationsPOST /api/v1/conversations/{id}/messages, which stores its201and replays it without appending a second message
Error codes
Every code the API defines, grouped by the status it is returned with.
400 Bad Request
The request itself is wrong. Fix it; sending it again unchanged fails the same way.
code | Meaning |
|---|---|
invalid_request | Malformed JSON, or a field that is missing or invalid. Carries required_version when the request uses a field that needs a newer Cruxy-Version pin. |
context_overflow | The input exceeds the tier's context window. |
invalid_schema | A response_format JSON schema is malformed, oversized, too deep, or uses an unsupported keyword. |
invalid_tool_schema | A tool's parameters schema exceeds a bound. Adds tool, bound, and limit. The fix belongs in your client, so a retry cannot help. |
file_not_text | A file named in file_ids cannot be read as text. Names the file; a document that could not be read also carries a reason. |
file_too_large | A file named in file_ids exceeds a size cap. Names the file, with bound, limit, and tokens. |
image_not_supported | A file named in file_ids on this turn is an image, and the tier serving the turn does not read images. Names the image and the tier, and lists the tiers that do in vision_tiers. |
moderation_rejected | The server-side input moderation gate refused the request's text. Carries hazard_codes. Not billed. |
pii_rejected | A memory or rule contains an email address, phone number, or card number. Rephrase without it and retry. |
rules_too_large | A ruleset exceeds a size cap. Names the cap. |
input_not_countable | POST /api/v1/tokens/count refuses to count this text and returns no count. |
invalid_version | The Cruxy-Version header is not a YYYY-MM-DD date. Answered on every route, before authentication. |
unknown_version | Cruxy-Version is a well-formed date this server does not support. The message lists the supported dates. Answered on every route, before authentication. |
invalid_payload | POST /api/v1/feedback only: the payload does not match the schema for its type. |
provider_not_supported | Native sign-in named a provider that is not supported. |
redirect_uri_not_allowed | Native sign-in named a redirect URI that is not allowlisted. |
scope_unsupported | Retired: never sent. It stays in the vocabulary so the string is never reused. |
401 Unauthorized
code | Meaning |
|---|---|
unauthorized | The Bearer credential is missing, malformed, unknown, expired, or revoked, or its holder no longer belongs to the organization it names. Each case gets the same answer, with WWW-Authenticate: Bearer. It is the only 401 code; see Get your API key. |
403 Forbidden
The credential is genuine, but it cannot make this call. Waiting never widens a credential, so retrying cannot succeed.
code | Meaning |
|---|---|
account_suspended | The key's owner's account is suspended. |
credential_quarantined | The organization the key belongs to could not be established, so the key is refused rather than trusted. Signing in again does not clear it; contact support. |
forbidden_bucket | The credential's billing kind cannot be billed on this route. No key issued today can reach it. |
key_scope_forbidden | The key is not scoped for this endpoint family, or for the memory, rules, or files feature a request field asked for. Names required_scope in the second case. Re-scope or re-issue the key. |
model_not_allowed | The key's model allowlist excludes the requested tier. |
key_read_only | The key may not perform billable operations. |
org_forbidden | The operation needs an organization owner or admin. |
workspace_forbidden | Workspace management needs an organization owner or admin. |
logs_forbidden | Request logs need an organization owner or admin. |
rules_forbidden | You may not read or change this ruleset. |
vault_forbidden | You take part in the vault but lack the permission the operation needs. |
conversation_forbidden | The conversation is in a vault you may read but not write. |
404 Not Found
The target does not exist, or you cannot see it. The API gives the same answer
for an id that is missing and one you cannot see, so a 404 never confirms
that an id exists.
code | What was not found |
|---|---|
user_not_found | A user. |
file_not_found | A file of yours under this key's organization, including one named in file_ids. |
request_not_found | A request_id from a call this account made, on POST /api/v1/feedback. |
memory_not_found | A memory. |
vault_not_found | A vault you take part in. |
vault_member_not_found | A vault member. |
org_member_not_found | An organization member. |
invitation_not_found | An invitation addressed to you that is still open. |
workspace_not_found | A workspace in your organization. |
log_not_found | A request log in your organization. |
conversation_not_found | A conversation you can see. |
message_not_found | A message in the conversation. |
prompt_not_found | A prompt in your workspace. |
prompt_version_not_found | That version of the prompt. |
eval_suite_not_found | An eval suite in your workspace. |
eval_run_not_found | An eval run in your workspace. |
eval_case_not_found | A case in that eval suite. |
409 Conflict
The request conflicts with existing state. Only idempotency_conflict has a
retry path.
code | Meaning |
|---|---|
idempotency_conflict | The Idempotency-Key was reused with a different body. Resend the original body to replay the stored response, or use a new key. |
vault_name_exists | A vault with this name already exists in your organization. |
vault_external_id_conflict | The external_id names a vault somebody else owns. |
vault_member_exists | The user is already a member of the vault. |
org_member_exists | The user is already a member of the organization. |
org_member_active | The member has not left the organization. |
workspace_slug_exists | A workspace with this slug already exists. |
workspace_not_empty | The workspace holds, or has held, a credential, so it cannot be deleted. |
workspace_is_default | The default workspace cannot be deleted. |
prompt_slug_exists | A prompt with this slug already exists. |
eval_suite_slug_exists | An eval suite with this slug already exists. |
413 Payload Too Large
code | Meaning |
|---|---|
payload_too_large | The upload or message is over its size cap. |
storage_quota_exceeded | The upload would take your files past your total storage allowance. |
422 Unprocessable Content
code | Meaning |
|---|---|
structured_output_unsupported | The tier that served the request does not support constrained decoding for response_format. |
structured_output_unsatisfied | The model's non-streaming output did not satisfy the requested JSON schema. The request is still billed. |
429 Too Many Requests
Every 429 carries Retry-After, and nothing is billed.
code | Meaning |
|---|---|
rate_limited | Too many requests: reason is rate (the per-minute request window) or concurrency (too many of your requests in flight at once). Retry after Retry-After. |
budget_exhausted | A signed-in plan's monthly or 12-hour burst pool is spent, or mira's monthly request cap. mira_available says whether the same request on mira would be served now. Clears at reset_at. |
key_spend_cap_exceeded | The key's monthly spend cap is spent. Clears at reset_at. |
workspace_spend_cap_exceeded | The workspace's monthly spend cap is spent. Clears at reset_at. |
credits_exhausted | A headless key's credit pool is drained. Clears at reset_at. |
credits_unavailable | A headless key's credit balance could not be read. Retry after Retry-After (2 seconds). |
headless_concurrency_limit | Chat only: a headless key already has its maximum requests in flight. Retry after Retry-After (1 second). |
5xx Server errors
On chat, every model failure adds tier, and none is billed.
code | Status | Meaning |
|---|---|---|
internal_error | 500 | An unexpected server fault, on any route. Retry with backoff. |
upstream_error | 502 | On a model call, the model provider rejected the request the gateway sent, and a retry fails the same way. On native sign-in, the identity provider could not be reached or answered unusably; retry. |
upstream_overloaded | 503 | The model is overloaded, and the gateway's own retries ran out. Retry with backoff. |
model_unavailable | 503 | The tier is temporarily unavailable: it kept failing and is cooling down, or it failed again on this request after the gateway's retries. Retry with backoff. |
all_models_unavailable | 503 | Chat only: model: "auto" was sent and every tier is unavailable. Retry with backoff. |
server_shutting_down | 503 | The gateway restarted, for a deploy, before it answered. Retry with backoff. |
limits_unavailable | 503 | A rate-limit or budget check could not be read, so the request was refused rather than served unmetered. Adds dimension. Retry after Retry-After (5 seconds). |
count_unavailable | 503 | POST /api/v1/tokens/count cannot count for this tier, because the count could disagree with billing. It does not clear on a retry. |
upstream_timeout | 504 | The model did not answer in time. With stream: false the gateway made one attempt and does not retry it; see Timeouts. |
Stream errors
A stream that fails after it opens has already sent its 200, so the code
arrives in the stream, not the status:
POST /api/v2/chatends with anerrorevent whosecodeis one ofupstream_error,upstream_overloaded,upstream_timeout,model_unavailable,server_shutting_down, orinternal_error.POST /v1/chat/completionsends with adata: {"error": {...}}frame.POST /api/v1/chatsends no error frame: the stream ends withoutdata: [DONE]; see Streaming.
Codes in request logs
client_closed_request (499) records that the caller disconnected before the
gateway had an answer. Nobody receives it; it appears only in request logs.
Next steps
- Reliability: deadlines, retries, idempotency, and platform status together.
- Request IDs and metadata: find the
request_idand quote it when you report a problem. - Rate limits and budgets: read your headroom before you hit a
429. - Streaming: detect a stream that failed partway.
- Get your API key: fix
401and403at the key.