Errors & limits¶
Every error is JSON with a stable shape (a machine-readable code is present where one applies):
{ "error": { "message": "…", "type": "…", "code": "…" } }
Branch on the HTTP status and, where there is one, on error.code. Message strings are
human-readable prose and can be reworded at any time; code values are stable. Never match on
message text.
Status codes¶
| Code | Meaning | What to do |
|---|---|---|
400 |
Malformed request, or a request no route can serve, type: "invalid_request_error", plus a stable code for the named cases below |
fix the request |
401 |
Missing or invalid API key | check the Authorization: Bearer header; the key may be revoked |
402 |
Workspace prepaid balance exhausted, or this key's budget tripped for the current period | top up on the Billing page (or let auto-recharge prevent it); for a budget, raise/clear it, use another key, or wait for the period reset |
403 |
Account or key suspended, the message names the address to write to (abuse@keln.ai) |
contact that address |
404 |
Unknown route | check the path (/v1/chat/completions) |
413 |
Request body over 32 MB | send less, 32 MB is enough for a full-window prompt on a 1M-context model |
429 |
Rate limited. A Retry-After header gives the wait in seconds; limits apply per key, and the rate-limit headers on every response show where you stand |
wait at least Retry-After, then retry (see below) |
502 |
All serving attempts failed | safe to retry; already rare because of hedging/failover |
503 |
Transient, no capacity for this model right now, or a brief internal overload | retry with backoff; this is a fast-fail, not a queue, and there is no Retry-After on a 503 |
401, 402, 403, 429, 502, and 503 carry type: "keln_error" and no code field,
the HTTP status is the whole machine-readable signal, so branch on it alone.
413 is the one error that isn't JSON: the body is plain text, not the envelope above.
Named 400 codes¶
Each of these carries type: "invalid_request_error" and a stable code:
code |
Means |
|---|---|
model_not_found |
the model id isn't in the catalog, check /v1/models |
variant_not_supported |
a model-id suffix Keln doesn't offer (:free, :online) |
web_search_name_reserved |
the request opts into web search and also defines a function tool named web_search |
web_search_unavailable |
web search is not enabled on this deployment |
modality_not_supported |
image parts sent to a text-only model |
context_length_exceeded |
prompt + max_tokens exceeds the model's context window, rejected before any tokens are billed; shorten the input or lower max_tokens, windows are in /v1/models |
reasoning_not_disableable |
you sent reasoning_effort: "none" on a model that cannot disable reasoning, omit the field (accept thinking) or use another model |
tools_not_supported |
no route serving this model can do tool calling for this request |
response_format_not_supported |
no route can honor the response_format mode you asked for, in the transport you asked for |
param_not_supported |
a parameter you demanded is supported by no route serving this model |
param_combination_not_supported |
each parameter is fine on its own, but no route supports that combination |
Per-route limitations vs model-wide gaps
A parameter that one particular serving route rejects is handled before dispatch and does
not surface as an error. When no route serving the model supports what you asked for, a
parameter, a combination of parameters, tool calling, a response_format mode, image
input, you get a 400 naming the gap. That judgement is made against your exact request,
including the mode you asked for and whether you asked for streaming.
Limits¶
Current defaults. Every limit answers with a 429 carrying Retry-After; nothing queues, and a
rate-limited request never reaches a model and never bills. If your workload needs more,
contact us and we'll raise it.
| Limit | Default | Scope |
|---|---|---|
| Request rate | 100 requests/second, burst to 200 | per key |
| Concurrent requests | 256 in flight | per workspace |
GET /v1/models |
60 per minute, burst to 120 | per IP (it's unauthenticated) |
| Request body | 32 MB | per request (413, not 429) |
Repeated failed authentication is also gated: more than 30 pre-auth failures in 30 seconds from
one address blocks that address for 30 seconds. Spend is controlled separately — the prepaid
balance and per-key budgets, which answer with a 402, not a
429.
Rate-limit headers¶
Requests are limited per key by a token bucket: a sustained rate plus burst headroom,
replenished continuously rather than reset on window boundaries. Successful /v1 responses and
rate-limit 429s report the bucket's state at admission (request errors that fail before
dispatch, like a 400 for an unknown model, don't carry them):
| Header | Meaning |
|---|---|
x-ratelimit-limit-requests |
the key's sustained rate, in requests per second |
x-ratelimit-remaining-requests |
whole requests admissible right now, burst headroom included |
x-ratelimit-reset-requests |
seconds until the bucket is completely full again |
A 429 additionally carries Retry-After: the computed wait, in seconds, until the next
request will be admitted. Honor it instead of guessing a backoff — the official OpenAI and
Anthropic SDKs already do this automatically.
No timeout error¶
Keln does not time out a request that is still being served. A slow attempt is hedged and
re-raced against fresher capacity. 504 is not part of the error surface.
Streaming requests that stall mid-generation are failed over automatically; you receive one continuous stream or a retryable error.
Retry guidance¶
429 is safe to retry after waiting the Retry-After seconds — a rate-limited request never
reaches a model and bills nothing. 503/502 are safe to retry (idempotent from Keln's side, a
failed attempt that produced nothing bills nothing). Use exponential backoff with jitter, e.g.
0.5 s → 1 s → 2 s → 4 s, cap 5 attempts. Do not tight-loop on 402, it clears only when
balance/budget changes.
Streaming semantics¶
- SSE chunks in OpenAI format; the stream ends with
data: [DONE], full details in Streaming. - Ask for usage in the final chunk with
"stream_options": {"include_usage": true}. - If a mid-stream failover occurred you still receive a single continuous stream; the final usage chunk covers the whole response.
Refusals are not errors¶
If the serving model refuses a prompt, that refusal is relayed to you as-is: an ordinary
successful response carrying what the model said, with finish_reason: "content_filter". It's a
model refusal, not an error envelope; check finish_reason if you want to branch on it.
Errors after HTTP 200¶
Once the first byte of a response is on the wire, the HTTP status is committed, a later total
failure is reported in the body, always with a machine-readable code:
- Streaming, a terminal chunk with
finish_reason: "error"and a top-levelerror.code: "stream_interrupted", then[DONE](shape). The output above it is partial; retry is safe, only delivered tokens bill. - Non-streaming, the standard error envelope with
code: "upstream_incomplete"as the body of the 200. Nothing was delivered, nothing bills; retry is safe.
Both are last-resort paths after every hedge, reroute, and rescue attempt failed. Check
error.code, not just the HTTP status.