docketrouter
DocumentationBrowse
Operating it

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

every error
{
  "error": {
    "message": "max_tokens above the 8192 ceiling",
    "type": "invalid_request_error"
  }
}
TypeStatusesMeaning
invalid_request_error400, 413, 422, and some 403 and 503Your request. Change it before sending it again.
authentication_error401The credential is missing, malformed, revoked, or the wrong kind for this endpoint.
payment_required_error402A spend cap: the key's period budget is used up, or the account has no headroom for a new key.
permission_error403The credential is valid and does not own this thing.
not_found_error404No such id, or no candidate satisfied the query.
rate_limit_error429A token bucket or a beta quota.
api_error500Our side, and not the model provider.
upstream_error502The model provider, or a timeout waiting on it.
service_unavailable_error503A dependency is not available.
Schema failures: message, type, and details

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.

400invalid_request_errorBody failed validation; model or a models[] entry is not on the allowlist; max_tokens above 8192.
{"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"}}
401authentication_errorMissing, malformed or revoked key. On /keys, an API key where a signed-in session is required.
{"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
402payment_required_errorOn /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
403permission_errorA key id that exists and is not yours. On /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
404not_found_errorUnknown key id, request id or model id.
{"error":{"message":"not found","type":"not_found_error"}}

{"error":{"message":"no callable model satisfies constraints","type":"not_found_error","task":"overall"}}   // POST /route
413invalid_request_errorRequest body above 512KB, or more than 64 messages, or more than 200,000 characters across all messages.
{"error":{"message":"request body above the 512KB limit","type":"invalid_request_error"}}

{"error":{"message":"too many messages or characters","type":"invalid_request_error"}}
422invalid_request_errorA retrieved case-file document scored 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"}}
429rate_limit_errorThe token bucket for this key, session or IP is empty. The 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
500api_errorA server-side failure that is not an upstream model failure. In practice: key creation could not complete.
{"error":{"message":"could not create key: …","type":"api_error"}}
502upstream_errorThe model provider failed, or the 90 second non-streaming timeout was hit. The detail is written to your usage row, not returned.
{"error":{"message":"upstream error; quote request_id req_75f51f07e28c4a818103 to support","type":"upstream_error"}}
503invalid_request_errorA private-pod key was used on a host with no pod endpoint configured. This is a deployment problem, not a transient one.
{"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.

StatusRetryableHow
429YesSleep for the retry-after header value in seconds, then send the same body. Do not add jitter shorter than the header.
502Yes, twice at mostExponential backoff from about 500ms. Log the request_id from the message on every attempt; each attempt gets a different one.
500OnceBackoff and retry once. If it repeats, it is not transient.
503NoOn the chat endpoint this means a pod endpoint is not configured. That is a deployment fix, not a wait.
400NoChange the request. Retrying a rejected model or an oversized max_tokens produces the identical error and still consumes a rate-limit token.
401NoFix the credential. A revoked key never recovers.
402Not until resets_atThe 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.
403NoYou do not own that object, or the key policy forbids the model.
404NoThe id does not exist, or no candidate matched.
413NoSplit the request. See the limits below.
422No, but recoverableThe 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");
}
Log the request id on every call, not only on failures

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.

both checks
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

LimitValueOn breach
Callable modelsThe allowlist, currently deepseek/deepseek-v4-flash. Applies to model and to every entry of models[].400
Request body512KB413
Messages per request64413
Total prompt characters200,000 across all messages413
max_tokens8,192400
models[]5 fallbacks400 (schema)
session_id128 characters400 (schema)
Rate limit, API key120 requests per minute sustained, burst of 40429 with retry-after and X-RateLimit-*
Rate limit, signed-in session30 per minute, burst of 10429
Rate limit, unauthenticated site demo5 per minute per IP, burst of 5429
Active keys per account3 during beta429
Spend per key, lifetimeThe key cap in USD, enforced upstream. Beta self-serve: $10.upstream refusal
Spend per key, per periodThe same cap applied per day, week or month when limit_reset is set.402 with resets_at, before any model call
Request timeout90 seconds non-streaming, 120 seconds streaming502, or an error chunk in the stream
Case file name200 characters400
Case file text2,000,000 characters per upload400
Case file chunks retrieved6 per request, each fenced at 1,500 characterssilently capped
Rule excerpts injected6 per request, each capped at 1,600 characterssilently capped
Cases retrieved5 from the index, or up to 4 from fallbackssilently capped
Prompt citations checked8silently capped
Answer citations checked12 uniquesilently capped
Usage CSV export5,000 rowssilently capped
POST /usage/sync200 rows per callsilently 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-after is 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.

HeaderMeaningUnits
X-RateLimit-LimitThe sustained rate for this caller class: 120 for a key, 30 for a session, 5 for the site demo.requests per minute
X-RateLimit-RemainingWhole 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-ResetWhen 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
on a 200
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 39
X-RateLimit-Reset: 1787855138
x-docketrouter-request-id: req_f60437cb80a541d8912c
402 is a budget, not a rate limit

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.

One 429 is not a quota problem

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.