Gateway API
Errors & retries
6 classes, chosen so each one implies a different response. Every failure carries the request id, including the ones that fail before authentication finishes.
type is the class. code repeats the HTTP status. error_code is present when there is a machine-readable reason, and is the field to branch on in code. request_id is also returned as x-lobstack-request-id and is the id of the trace row, so quoting it in a support thread turns a description into a lookup. The taxonomy is deliberately small: 6 buckets that each imply a different action beats a long list nobody can group by.
| error_code | Status | Meaning |
|---|---|---|
| RATE_LIMITED | 429 | The caller went over the Gateway's own rate limit. |
| CONCURRENCY_LIMIT | 429 | The organization had more calls running at once than its plan allows. |
| CLIENT_BUDGET_EXHAUSTED | 402 | A client tag with a hard stop reached its monthly budget. |
| PERSON_CAP_REACHED | 402 | The person who created this API key reached their monthly spend cap. |
| KEY_CAP_REACHED | 402 | This API key reached its monthly spend cap. |
| CLIENT_TAG_INVALID | 400 | The client tag in the header or metadata.client is malformed. |
| INVALID_PARAMETER | 400 | A forwarded parameter has the wrong type or range. The message names it. |
| The provider's own code | Varies | Passed through when the provider sent one. |
The 6 classes
| Class | Status | Whose problem | Retry? |
|---|---|---|---|
| auth | 401, 403 | The credential | No |
| quota | 402, 429 | Allowance or rate limit | 402 no, 429 yes |
| validation | 400, 409, 422 | The request body | No |
| provider | 502, 503, other 5xx | The upstream model provider | Yes, with backoff |
| timeout | 408, 504 | We gave up waiting | Yes |
| internal | 500 | Ours | Once, then report it |
The mapping runs from status to class, so a status the Gateway raises and a status a provider returns land in the same bucket. The statuses above are the ones each class claims by name; any other 5xx is attributed to the provider, which is why provider reads “other 5xx” and internal does not — 500 is claimed by name and never reaches that rule.
An unknown throw is classified internal rather than provider: blaming upstream for our own crashes would make the error rate look better than it is. A sub-500 status no class claims goes the same way, for the same reason.
auth
The credential was rejected. Causes: no bearer token; a key that does not match the lsk_<env>_<8 hex><48 hex> shape; a key that is unknown, revoked or expired; a key without the inference scope; and a legacy agent credential whose row is not active, which returns 403 with that status in the message.
Do not retry. Revoked and expired keys are reported distinctly from unknown ones in the message, because "this key was revoked" beats "unauthorized" when somebody is debugging at two in the morning, but every one of them is a rejection.
quota
Two different things share this class. A 402 means an allowance is gone: the plan's for the period, or a client's monthly budget. A 429 means a rate limit was hit.
The Gateway's own limit is 120 requests a minute per API key. It is counted on each server instance and in a one-minute window shared across instances. If the shared counter cannot be read, the per-instance count decides alone. Its 429 carries error_code: "RATE_LIMITED", retry-after in seconds and x-lobstack-request-id. A provider's 429 passes through with its status.
Each plan also has a number of chat and embeddings calls an organization may have running at once: 5 on Free, 10 on Developer, 30 on Agency, 100 on Scale. Every response carries x-lobstack-concurrency (calls in flight, this one included) and x-lobstack-concurrency-limit. For now nothing is refused: a response over the limit carries x-lobstack-concurrency-warning. Later, a call over the limit will be refused with 429 and error_code: "CONCURRENCY_LIMIT". Calls arriving in the same instant can pass the limit by a few.
A 402 is a refusal, not a truncation. The allowance is checked once, after the request has been authenticated and its body parsed and before the router picks a model or a single byte goes to a provider — so the request that finds the wall is not dispatched at all, and no answer is ever cut off part-way to make the numbers come out. There is no second check during generation: a response that has begun streaming finishes, and the round that takes you over is the last one served rather than the first one severed. Failures inside a stream covers what can appear mid-response, and quota is not among them.
Which allowance depends on the meter, and the message says so rather than making the caller guess. x-lobstack-quota-meter on the same response carries spend, requests or legacy; the three are described on Pricing & plans.
The allowance is enforced for every credential: API keys and legacy agent credentials alike.
A client tag with a monthly budget and a hard stop is refused with a 402 and error_code: "CLIENT_BUDGET_EXHAUSTED" once its spend for the month reaches the budget. A tag without a hard stop is never refused. See Clients.
retry-after is in seconds and is computed from the period reset, so on a 402 it is usually days rather than seconds. Treat it as "this will not clear on its own before then", not as a backoff hint: top up or upgrade the plan. Purchased balance is consumed after the included allowance — as dollars on a spend meter, and as x-lobstack-quota-credits on the counted meters.
A request allowance counts this calendar month's successful traced requests. Requests that failed at the provider are excluded, so an outage does not consume the allowance, and the same rule holds on a spend meter: an error row accrues no spend. A quota lookup that itself errors fails open — an unavailable quota table must never stop inference, so an allowance that cannot be read is treated as empty rather than as exhausted and the failure is logged rather than turned into a 402.
validation
The request was malformed, and it will fail identically until you change it. Causes: messages missing or empty; a body that is not JSON; a model key the registry does not know, including after alias resolution; a model that exists but is not available in managed mode; BYOK mode with no key on the agent; a malformed forwarded parameter (INVALID_PARAMETER, naming it); and a malformed client tag (CLIENT_TAG_INVALID).
Provider 400s also land here, because the status passes through with the provider's own message inside. That is the right bucket — the caller can fix it — but it means a validation error can be about something you never wrote yourself. The worked example below is exactly that case.
provider
The upstream failed and it is not your fault. Causes: the network call to the provider threw, which becomes a 502; the provider returned a 5xx, which passes through with its status; and 503 when no provider key is available — none configured on the deployment, and none added by your organization.
A provider 401 is deliberately remapped to 502. Lobstack's own credential being wrong is our failure, and reporting it as 401 would tell the caller to go and check a key that is fine. The remap also applies to a call that ran on your own provider key, so a 502 there can mean the provider refused your key.
Retry with exponential backoff and jitter. The exception is the 503 whose message says no AI provider is available to this key — that one is a configuration problem and will fail until a key is added. On a paid plan you can add your own in Settings › Provider keys.
timeout
The provider call was aborted. Adapters abort at 120 seconds by default, and the route itself allows up to 300 seconds. Anything whose message looks like an abort or a timeout is classified here even when it arrives as an unrecognised throw.
Retryable, but read the warning about duplicate billing below before you do it automatically.
internal
Our bug. Any throw that is not a recognised Gateway error and does not look like a timeout lands here as a 500. Retry once. If it repeats, the request id is the whole conversation: the trace row holds the status, the latency, the model, the credential and the first 1,000 characters of the error message.
Worked example: the temperature 400
Anthropic deprecated the sampling parameters on Opus 4.7 and everything after it, including Opus 5, Sonnet 5 and the Fable line. The API does not ignore a temperature on those models; it returns a hard 400.
Passed through the Gateway, that arrived as a 400 of class validation with the provider's message wrapped inside. It was the most common failure in this account's history, and not because customers were setting temperatures: the Gateway sent temperature: 0.7 on every Anthropic request whether or not anyone had asked for one, and every one of them failed.
Two changes removed it. A temperature is forwarded only when the caller actually set one — inventing a sampling default was never the Gateway's decision to make, since it silently changed the character of the output. And it is forwarded only to models that still accept it, matched on the provider-native model id that goes on the wire. When one is dropped the response says so in x-lobstack-dropped-params: temperature. A check in the repo holds the expected answer for every Anthropic model in the registry, so a new one cannot default in by accident.
If you see this 400 today, you sent a temperature to a model that rejects it through a path that is not this Gateway. The behaviour of each family is tabulated on Chat Completions.
Retrying
| Class | What to do |
|---|---|
| auth | Fix the credential. Never retry. |
| quota | 402: top up, upgrade, or wait for the reset. 429: back off exponentially with jitter. |
| validation | Fix the body. Retrying reproduces the failure exactly. |
| provider | Retry with backoff, except a 503 saying no managed key is configured. |
| timeout | Retry, aware that the first attempt may have completed. |
| internal | Retry once, then report the request id. |
Failures inside a stream
A stream that fails part-way through cannot change its status code, which is already 200 and whose headers are long gone. The Gateway sends an error object into the stream and then [DONE]. The trace row records the real status, so a stream that appeared to succeed to a naive client still shows as a failure in the usage API.
type, the HTTP-style status as code, and error_code when there is one. It used to report "type": "gateway_error"; since 2026-09-28 it reports the class.Counting failures
GET /api/v1/usage returns summary.errors_by_class over any range, which is the fastest way to see whether a bad hour was upstream or yours. A failed round also writes a ledger row with zero tokens, so error rate stays computable from the ledger alone. See Metering & cost.