Gateway error responses
Errors under /gw/ never use the dashboard’s problem+json. The gateway answers in the shape of the provider SDK you are using, so the official clients raise their usual exceptions and retry the statuses they normally retry.
Which shape you get follows the path: /gw/v1/messages gets the Anthropic shape, and /gw/v1/chat/completions and /gw/v1/responses get the OpenAI shape. GET /gw/v1/models gets the Anthropic shape when the request carries an anthropic-version header.
Every error response carries:
X-Request-ID, the id of the call (also in the span and, for Anthropic, in the body);X-Spanlight-Code, the stable Spanlight code from the table below;Retry-Afterin whole seconds (rounded up, at least 1) when waiting helps.
Before you start
- A gateway key (spl_gw_...) and a route that works. See the gateway quickstart
- The official OpenAI or Anthropic SDK, or any HTTP client
The two shapes
Section titled “The two shapes”OpenAI:
{ "error": { "message": "Rate limit reached for gateway key spl_gw_abcdefghijkl: 60 requests per minute.", "type": "requests", "param": null, "code": "rate_limit_exceeded", "spanlight_code": "RATE_LIMITED" }}Anthropic:
{ "type": "error", "error": { "type": "rate_limit_error", "message": "Rate limit reached for gateway key spl_gw_abcdefghijkl: 60 requests per minute.", "spanlight_code": "RATE_LIMITED" }, "request_id": "4f2c0a7e9b1d4c3f8a5e6d7c8b9a0f1e"}spanlight_code is added to both. Match on it, not on the message, which can change.
Codes the gateway produces
Section titled “Codes the gateway produces”spanlight_code |
Status | OpenAI type / code |
Anthropic type |
When |
|---|---|---|---|---|
UNAUTHORIZED |
401 | invalid_request_error / invalid_api_key |
authentication_error |
The key is missing, malformed, unknown, revoked or has the wrong secret, or Authorization and x-api-key carry different keys |
RATE_LIMITED |
429 | requests / rate_limit_exceeded (tokens when the tokens-per-minute limit was hit) |
rate_limit_error |
The organization ceiling, the key’s requests per minute or its tokens per minute was exceeded. Has Retry-After |
BUDGET_EXCEEDED |
402 | insufficient_quota / insufficient_quota |
billing_error |
A blocking budget was exceeded and the call was refused before it reached a provider |
MODEL_NOT_ALLOWED |
404 | invalid_request_error / model_not_found (param: "model") |
not_found_error |
The model is not in the key’s allowed_models |
NO_COMPATIBLE_TARGET |
400 | invalid_request_error |
invalid_request_error |
None of the route’s targets can serve the surface, for example a messages call on a route with only OpenAI credentials |
INVALID_REQUEST |
400 | invalid_request_error |
invalid_request_error |
The body is not a JSON object with a string model, or uses an unsupported Content-Encoding, or does not decompress |
PAYLOAD_TOO_LARGE |
413 | invalid_request_error |
request_too_large |
The body is over 10 MB, counted after decompression |
UPSTREAM_UNREACHABLE |
502 | server_error |
api_error |
Every attempt failed to connect, or the provider answered with a redirect, which the gateway does not follow |
UPSTREAM_BLOCKED |
502 | server_error |
api_error |
The credential’s host resolves to a private or local address. Never retried |
UPSTREAM_TIMEOUT |
504 | server_error |
timeout_error |
The route’s timeout_ms budget ran out |
NOT_FOUND |
404 | invalid_request_error |
not_found_error |
An unknown path under /gw/ |
METHOD_NOT_ALLOWED |
405 | invalid_request_error |
invalid_request_error |
A known path with the wrong method; the Allow header lists the right ones. A browser preflight (OPTIONS) also gets this, because the gateway sends no CORS headers |
SERVICE_UNAVAILABLE |
503 | server_error |
overloaded_error |
The database is too busy to check the key or to serve the call: no pooled connection came free in time, or a query ran past its statement timeout. Retry-After: 5; both official SDKs retry it |
INTERNAL_ERROR |
500 | server_error |
api_error |
An unexpected gateway failure. The message names the request id |
ERROR |
as raised | invalid_request_error below 500, server_error from 500 |
invalid_request_error below 500, api_error from 500 |
Any other HTTP status the framework raises |
Errors from the provider
Section titled “Errors from the provider”When the provider itself answers with an error that the route does not retry, the gateway returns that response unchanged: the provider’s status and body, with X-Spanlight-Code: UPSTREAM_ERROR added. Your SDK raises the provider’s own exception. The call’s span is marked as an error and records the provider’s error type and code, its request id and its rate-limit headers; see What a span records.
Only a short allowlist of provider headers is passed back: content-type, retry-after, retry-after-ms, x-ratelimit-*, anthropic-ratelimit-*, anthropic-priority-* and openai-processing-ms; the provider’s request-id becomes x-upstream-request-id.
Errors from the Lab
Section titled “Errors from the Lab”A Lab fault is returned in the provider’s shape and wording, with spanlight_code and X-Spanlight-Code set to FAULT_<SCENARIO> and the header X-Spanlight-Fault: <scenario>:
| Scenario | Code | Status |
|---|---|---|
auth_expired |
FAULT_AUTH_EXPIRED |
401 |
scope_denied |
FAULT_SCOPE_DENIED |
403 |
rate_limited |
FAULT_RATE_LIMITED |
429 with Retry-After |
unsupported_parameter |
FAULT_UNSUPPORTED_PARAMETER |
400 |
provider_5xx |
FAULT_PROVIDER_5XX |
500, 502, 503 or 529 |
timeout |
FAULT_TIMEOUT |
504 |
The other three scenarios (malformed_json, truncated_stream, slow_response) change a real answer and have no error body of their own.
The error class of a gateway span
Section titled “The error class of a gateway span”Every call the gateway answers leaves a span, refused calls included, and a failed span gets an error class. When the gateway made the error itself, the class comes from its code, so Spanlight’s own refusals and failures are never counted as the provider’s:
spanlight_code |
Error class |
|---|---|
INVALID_REQUEST, NO_COMPATIBLE_TARGET, MODEL_NOT_ALLOWED, PAYLOAD_TOO_LARGE, NOT_FOUND, METHOD_NOT_ALLOWED |
client: the request was wrong |
UNAUTHORIZED |
auth |
RATE_LIMITED |
rate_limit, with the rate-limit kind gateway: your gateway key’s own limit, not the provider’s |
BUDGET_EXCEEDED, UPSTREAM_BLOCKED, INTERNAL_ERROR, SERVICE_UNAVAILABLE |
gateway: Spanlight’s policy or Spanlight failing |
UPSTREAM_TIMEOUT |
timeout |
UPSTREAM_UNREACHABLE |
network |
A provider error passed through as UPSTREAM_ERROR is classed by the provider’s status, like any other span. A body the gateway cannot forward as strict JSON, such as one with NaN in it, is 400 INVALID_REQUEST and class client.
A call that RATE_LIMITED refused is recorded although the gateway never read its body: the span is named after the surface, such as chat_completions, and has no model, no input and no target. It records the Retry-After your client was told. These spans are the first to be dropped when the gateway’s span recorder is busy, and they are counted as lost; see Telemetry completeness.
What a span records
Section titled “What a span records”Besides the route, the target and the attempt count described in Routes, retries and fallbacks, a gateway span carries:
| Attribute | Value |
|---|---|
spanlight.gateway.status |
The status the gateway answered with. Always set. |
spanlight.gateway.error_code |
The spanlight_code when the gateway made the error itself. Absent for a success and for a provider error passed through. |
provider.error.type, provider.error.code |
The provider’s error type and code, for a provider error passed through. The code is OpenAI’s error.code, or Anthropic’s error.details.error_code. |
provider.request_id |
The provider’s request id, also sent to your application as x-upstream-request-id. |
provider.ratelimit |
The provider’s rate-limit headers, as an object of header name to value. On a failed call, every header of the allowlist; on a successful one, only the limit and remaining headers. |
spanlight.gateway.attempt_log |
One entry per attempt, in order: target (the index in the route’s targets), name (the credential’s name), status (the provider’s status, null without a response), error (timeout, connection_error, blocked, cancelled or response_too_large, else null), duration_ms and retry_after_s. |
The rate-limit allowlist has 29 names: retry-after, retry-after-ms, OpenAI’s x-ratelimit-limit-*, x-ratelimit-remaining-* and x-ratelimit-reset-* for requests, tokens and project tokens, and Anthropic’s anthropic-ratelimit-* (requests, tokens, input tokens, output tokens) and anthropic-priority-* (input and output tokens) limit, remaining and reset headers. A value longer than 64 characters, or not printable ASCII, is dropped. No other header, and nothing of your key, ever reaches a span.
A route allows up to 40 attempts. The attempt log keeps at most 10 entries: the first 9 and the last. spanlight.gateway.attempts still holds the true count. An entry holds no headers, bodies or key material.
A cache hit never reached a provider, so its span has no provider fields. A stream that fails after the provider answered 200 keeps the limit and remaining headers of that answer.
Statuses and the official SDKs
Section titled “Statuses and the official SDKs”| Status | OpenAI and Anthropic SDKs |
|---|---|
| 429, 500, 502, 503, 504, 529 | Retry with backoff, and honour Retry-After |
| 401, 402, 404, 400, 413 | Raise at once |
That is why a blocked budget is 402 and an exceeded rate limit 429: a client should not retry the first and should wait for the second.
Check it worked
Section titled “Check it worked”Send a call with a key the gateway does not know:
curl -i "<host>/gw/v1/chat/completions" \ -H "Authorization: Bearer spl_gw_not_a_real_key" \ -H "Content-Type: application/json" \ -d '{"model": "gpt-4.1-mini", "messages": []}'The answer is 401 with the headers X-Request-ID and X-Spanlight-Code: UNAUTHORIZED, and a body in the OpenAI shape:
{ "error": { "message": "Invalid or missing Spanlight gateway key.", "type": "invalid_request_error", "param": null, "code": "invalid_api_key", "spanlight_code": "UNAUTHORIZED" }}Send the same call to /gw/v1/messages and the body takes the Anthropic shape, with "type": "authentication_error".
Troubleshooting
Section titled “Troubleshooting”- The error body is not in a provider shape, or is
problem+json. The request did not reach/gw/. Dashboard routes answer inproblem+json; check the base URL (see the gateway quickstart). - The status and body are the provider’s own, with
X-Spanlight-Code: UPSTREAM_ERROR. The provider answered with an error that the route does not retry, and the gateway passed it on unchanged. Read the provider’s message; see Errors from the provider. - A
429or503that the SDK retries by itself. That is the expected client behaviour for those statuses; wait forRetry-After. See Statuses and the official SDKs. - A code that is not in the table. Read
X-Request-IDfrom the response and search the api or gateway log for it (log lines carryrequest_id); see Run the LLM gateway.