Skip to content

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-After in 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

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.

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

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.

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.

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.

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.

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.

Send a call with a key the gateway does not know:

Terminal window
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".

  • The error body is not in a provider shape, or is problem+json. The request did not reach /gw/. Dashboard routes answer in problem+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 429 or 503 that the SDK retries by itself. That is the expected client behaviour for those statuses; wait for Retry-After. See Statuses and the official SDKs.
  • A code that is not in the table. Read X-Request-ID from the response and search the api or gateway log for it (log lines carry request_id); see Run the LLM gateway.