DocumentationBrowse
Errors, retries and limits
Every /api/v1 route answers with one envelope: { "error": { "message", "type" } }. The message is written to be read by a person; the type is written to be branched on by a program.
Schema failures use the same envelope, with the full validator field map added under details, so one error handler covers every route.
The envelope
{
"error": {
"message": "max_tokens above the 8192 ceiling",
"type": "invalid_request_error"
}
}| Type | Statuses | Meaning |
|---|---|---|
invalid_request_error | 400, 413, 422, and some 403 and 503 | Your request. Change it before sending it again. |
authentication_error | 401 | The credential is missing, malformed, revoked, or the wrong kind for this endpoint. |
payment_required_error | 402 | A spend cap: the key's period budget is used up, or the account has no headroom for a new key. |
permission_error | 403 | The credential is valid and does not own this thing. |
not_found_error | 404 | No such id, or no candidate satisfied the query. |
rate_limit_error | 429 | A token bucket or a beta quota. |
api_error | 500 | Our side, and not the model provider. |
upstream_error | 502 | The model provider, or a timeout waiting on it. |
service_unavailable_error | 503 | A dependency is not available. |
A body that fails validation on POST /files, POST /keys, PATCH /keys/:id, POST /route or the billing endpoints returns 400 with the first failing field in message, the usual type, and the complete field map under details:
{"error":{"message":"invalid limit_usd: Too small: expected number to be >0","type":"invalid_request_error","details":{"fieldErrors":{"limit_usd":["Too small: expected number to be >0"]},"formErrors":[]}}}
The chat endpoint folds the same field map into message instead. Either way, error.type is always there to branch on. Some errors add fields beside message and type, like the 402's resets_at or a routing failure's request_id; they are documented per status below and in the OpenAPI Error schema.
Every status
Bodies below are verbatim from the live API.
{"error":{"message":"model not enabled: openai/gpt-5. Only benchmarked models are available right now: deepseek/deepseek-v4-flash","type":"invalid_request_error"}}
{"error":{"message":"max_tokens above the 8192 ceiling","type":"invalid_request_error"}}
{"error":{"message":"invalid body: {\"model\":[\"Invalid input: expected string, received undefined\"],\"messages\":[\"Too small: expected array to have >=1 items\"]}","type":"invalid_request_error"}}{"error":{"message":"Missing or invalid API key. Pass 'Authorization: Bearer dr-…'. mint one at https://docketrouter.ai/keys","type":"authentication_error"}} // /chat/completions
{"error":{"message":"missing or invalid API key","type":"authentication_error"}} // /usage, /usage/:id, /usage/sync, /auth/key
{"error":{"message":"API key or sign-in required","type":"authentication_error"}} // /files
{"error":{"message":"sign in or admin token required","type":"authentication_error"}} // /keys/chat/completions, a key on a limit_reset schedule has spent its limit_usd for the current period; the body says when the cap frees up. On POST /keys, the account has no spend headroom left for a new key.{"error":{"message":"key spend cap reached for this period; resets at 2026-08-28T00:00:00.000Z","type":"payment_required_error","limit_usd":5,"period_spent_usd":5.000213,"limit_reset":"daily","resets_at":"2026-08-28T00:00:00.000Z"}} // /chat/completions
{"error":{"message":"no spend headroom left on this account (trial allowance $10 is already allocated to your existing keys); add credits or lower a key's cap","type":"payment_required_error"}} // POST /keys/chat/completions the same status is used for a private-pod key reaching outside the pod, with type invalid_request_error.{"error":{"message":"not allowed","type":"permission_error"}} // GET, PATCH, DELETE /keys/:id and /keys/:id/rotate
{"error":{"message":"This key is private_pod: only in-house models (local/…) are allowed; the prompt never leaves the pod","type":"invalid_request_error"}} // /chat/completions{"error":{"message":"not found","type":"not_found_error"}}
{"error":{"message":"no callable model satisfies constraints","type":"not_found_error","task":"overall"}} // POST /route{"error":{"message":"request body above the 512KB limit","type":"invalid_request_error"}}
{"error":{"message":"too many messages or characters","type":"invalid_request_error"}}hostile and injection_policy is "block".{"error":{"message":"blocked: embedded instructions detected in 1 case-file document(s): opposing-counsel-exhibit-14.txt. Set docketrouter.injection_policy=\"flag\" to analyze them as evidence instead.","type":"invalid_request_error"}}retry-after header carries the wait in seconds, and the X-RateLimit-* headers say how big the bucket is and when it refills.retry-after: 1
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1787856001
{"error":{"message":"rate limit exceeded; retry in 1s","type":"rate_limit_error"}}
{"error":{"message":"max 3 active keys per account during beta","type":"rate_limit_error"}} // POST /keys: a quota, not a rate limit{"error":{"message":"could not create key: …","type":"api_error"}}{"error":{"message":"upstream error; quote request_id req_75f51f07e28c4a818103 to support","type":"upstream_error"}}{"error":{"message":"private_pod requested but no pod endpoint is configured on this host","type":"invalid_request_error"}}What to retry
Retrying the wrong status wastes your rate limit and fixes nothing.
| Status | Retryable | How |
|---|---|---|
| 429 | Yes | Sleep for the retry-after header value in seconds, then send the same body. Do not add jitter shorter than the header. |
| 502 | Yes, twice at most | Exponential backoff from about 500ms. Log the request_id from the message on every attempt; each attempt gets a different one. |
| 500 | Once | Backoff and retry once. If it repeats, it is not transient. |
| 503 | No | On the chat endpoint this means a pod endpoint is not configured. That is a deployment fix, not a wait. |
| 400 | No | Change the request. Retrying a rejected model or an oversized max_tokens produces the identical error and still consumes a rate-limit token. |
| 401 | No | Fix the credential. A revoked key never recovers. |
| 402 | Not until resets_at | The body's resets_at is the UTC moment the period cap frees up. Sleep until then, raise limit_usd, or move the work to another key. Retrying sooner returns the identical 402. |
| 403 | No | You do not own that object, or the key policy forbids the model. |
| 404 | No | The id does not exist, or no candidate matched. |
| 413 | No | Split the request. See the limits below. |
| 422 | No, but recoverable | The message names the offending documents. Exclude them, or switch injection_policy to flag and handle the finding. |
const RETRYABLE = new Set([429, 500, 502]);
async function call(body: unknown, maxAttempts = 3) {
let lastRequestId: string | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(`${BASE}/chat/completions`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
// Log the request id whatever happens. It is the only handle support can use,
// and on a 502 it is also embedded in the error message.
lastRequestId = res.headers.get("x-docketrouter-request-id") ?? lastRequestId;
if (res.ok) return res.json();
if (!RETRYABLE.has(res.status) || attempt === maxAttempts - 1) {
const err = await res.json().catch(() => ({}));
throw new Error(`${res.status} ${err?.error?.type ?? "unknown"}: ${err?.error?.message ?? ""} [${lastRequestId}]`);
}
// 429 tells you exactly how long to wait. Everything else gets exponential backoff.
const waitMs = res.status === 429
? Number(res.headers.get("retry-after") ?? 1) * 1000
: 500 * 2 ** attempt + Math.random() * 250;
await new Promise((r) => setTimeout(r, waitMs));
}
throw new Error("unreachable");
}x-docketrouter-request-id is on the response headers before the body is parsed, and docketrouter.request_id is in the body. It is the key into GET /usage/:request_id, where the upstream error message, the provider that served the request, the exact token counts and the verification report all live. Support cannot find anything without it.
Two failures that are not error statuses
A streaming request that fails upstream returns 200
The status is committed before the model is called. A mid-stream failure arrives as a content delta reading [upstream error; request_id req_…] with finish_reason: "error". A client that only checks res.ok will render the error text as an answer.
A 200 can carry an empty answer
When reasoning tokens consume the whole max_tokens budget you get finish_reason: "length" and an empty choices[0].message.content. Give the model 1,000 output tokens or more, and check for the empty case explicitly.
const choice = data.choices[0];
if (choice.finish_reason === "error") throw new Error(`upstream failed: ${dr.request_id}`);
if (!choice.message.content.trim() && choice.finish_reason === "length") {
throw new Error(`empty answer, raise max_tokens: ${dr.request_id}`);
}Limits
| Limit | Value | On breach |
|---|---|---|
| Callable models | The allowlist, currently deepseek/deepseek-v4-flash. Applies to model and to every entry of models[]. | 400 |
| Request body | 512KB | 413 |
| Messages per request | 64 | 413 |
| Total prompt characters | 200,000 across all messages | 413 |
max_tokens | 8,192 | 400 |
models[] | 5 fallbacks | 400 (schema) |
session_id | 128 characters | 400 (schema) |
| Rate limit, API key | 120 requests per minute sustained, burst of 40 | 429 with retry-after and X-RateLimit-* |
| Rate limit, signed-in session | 30 per minute, burst of 10 | 429 |
| Rate limit, unauthenticated site demo | 5 per minute per IP, burst of 5 | 429 |
| Active keys per account | 3 during beta | 429 |
| Spend per key, lifetime | The key cap in USD, enforced upstream. Beta self-serve: $10. | upstream refusal |
| Spend per key, per period | The same cap applied per day, week or month when limit_reset is set. | 402 with resets_at, before any model call |
| Request timeout | 90 seconds non-streaming, 120 seconds streaming | 502, or an error chunk in the stream |
| Case file name | 200 characters | 400 |
| Case file text | 2,000,000 characters per upload | 400 |
| Case file chunks retrieved | 6 per request, each fenced at 1,500 characters | silently capped |
| Rule excerpts injected | 6 per request, each capped at 1,600 characters | silently capped |
| Cases retrieved | 5 from the index, or up to 4 from fallbacks | silently capped |
| Prompt citations checked | 8 | silently capped |
| Answer citations checked | 12 unique | silently capped |
| Usage CSV export | 5,000 rows | silently capped |
POST /usage/sync | 200 rows per call | silently capped |
How the rate limit actually behaves
- It is a token bucket. A key starts with 40 tokens and refills at 2 per second up to 120 per minute. A burst is genuinely a burst: spend 40 in a second and you wait.
retry-afteris the exact number of seconds until one token is available, rounded up, with a floor of 1. Honour it rather than guessing.- The limit is checked before the model allowlist, so malformed model names still consume tokens. See the validation order.
- Size your concurrency for the sustained rate, not for the burst. The headers below tell you where the bucket stands after each call, so a client can throttle before it ever sees a 429.
The rate-limit headers
Every POST /chat/completions response, success or 429, carries three headers describing the caller's bucket. They are listed in access-control-expose-headers, so a browser client can read them too. The admin token has no bucket and gets none of them.
| Header | Meaning | Units |
|---|---|---|
X-RateLimit-Limit | The sustained rate for this caller class: 120 for a key, 30 for a session, 5 for the site demo. | requests per minute |
X-RateLimit-Remaining | Whole tokens left in the burst bucket after this request. 0 on a 429. Because the bucket refills continuously, two calls a second apart can show the same number. | requests |
X-RateLimit-Reset | When the bucket is full again (on a success) or when the next token arrives (on a 429). Compare it to Date.now() / 1000; it is not a delay. | Unix time, seconds |
X-RateLimit-Limit: 120 X-RateLimit-Remaining: 39 X-RateLimit-Reset: 1787855138 x-docketrouter-request-id: req_f60437cb80a541d8912c
A key with a limit_reset schedule that has spent its period budget answers 402, not 429, and carries no retry-after. The wait is in the body as resets_at, and it can be hours. See limit_reset.
The beta key quota also answers 429, with the message max 3 active keys per account during beta. Same status, different meaning, and it comes from POST /keys rather than from the chat endpoint. Read the message before you build a backoff around it.
Something here wrong or missing? Mail hello@docketrouter.ai with the request_id and we will fix the docs or the API, whichever is broken.