Troubleshooting
Symptom-first diagnosis for the gateway: for each status, error code or behavior you see, the likely cause, what to check and how to fix it, with a link to the rule in the spec. It is for operators and developers; planned work such as deployment and rotation is in Operations.
Read byPlatform operatorSecurity engineerDatabase administratorApplication developer
The statuses the gateway generates itself, with their error codes and sections, are tabled in spec §2.5. A body without the {"error", "message"} shape came from the upstream, not from the gateway.
Start here#
- Take the
x-request-idof the failing response and search the logs for it. Request and refusal lines carry the route, the caller's subject and the workspace, and the trace ID next to the request ID. - Read the
/readyzbody: it reports whether a configuration is loaded, the last reload error (config.lastError) and the session store. - Read the running-policy lines the gateway logs for each configuration generation: they show what every route accepts, checks and forwards, and the permissive-setting warnings.
- Look at the metric that counts the symptom; the table below names it.
| Symptom | Metric |
|---|---|
| Rejected callers | gateway_auth_failures_total{reason} |
| Access outages | gateway_access_unavailable_total, gateway_access_stale_total |
| Cross-site refusals | gateway_cross_site_refusals_total |
| Rate-limit refusals | gateway_rate_limited_total, gateway_rate_limit_fallback_total |
| Upstream failures | gateway_upstream_failures_total, gateway_upstreams_skipped_total, gateway_request_errors_total |
| Shared-state trouble | gateway_shared_state_errors_total |
| Broken route table | gateway_config_reloads_total{result="failed"} |
| WebSocket refusals | gateway_websocket_refusals_total{reason} |
Startup and readiness#
The gateway refuses to start#
- Cause. A production variable is missing, a value does not parse, the runtime is older than Bun 1.4.3, or an upstream client certificate is set without its key (or the reverse).
- Check. The error message names the variable or the runtime version it found.
- Fix. Set the variable the error names, or its escape hatch (required in production); run Bun 1.4.3 or later.
- Rule. spec §4.
The control plane refuses to start#
- Cause.
GATEWAY_CONTROL_STATE_URLis unset, the token map cannot be read, a token is shorter than 16 characters, or a token equals another secret such as the shared-state password. - Check. The startup error, and the JSON of the file named by
GATEWAY_CONTROL_TOKENS_FILE. - Fix. Give every operator a distinct token of 16 characters or more, with one scope.
- Rule. spec §12.7.
/readyz answers 503#
- Cause. No route table has ever loaded, the session store is unreachable (
sessionStore: unavailable), or the process is shutting down (bodydraining). - Check. The
/readyzbody. A setconfig.lastErroralone does not make a replica unready. - Fix. Fix the first route table, restore the shared state, or wait for the drain to finish.
- Rule. spec §12.3, §12.4, §12.5.
Every request gets 503 not_ready#
- Cause. The replica started without a valid route table and none has loaded since.
- Check.
config.lastErrorin/readyz; withGATEWAY_CONFIG_SOURCE=store, whether the store is empty and theGATEWAY_CONFIGfallback file is valid. - Fix. Correct the file or write a valid revision through the control plane.
- Rule. spec §2.3, §3.4.
A route-table change has no effect#
- Cause. The new table was refused as a whole: an unknown key, two ambiguous routes, a route claiming a reserved path,
accessCheckwith modenone, or a rewrite naming a placeholder the path lacks. - Check.
config.lastErrorin/readyzandgateway_config_reloads_total{result="failed"}; in store mode,GET /v1/replicasshows the revision each replica serves. - Fix. Validate the table with
POST /v1/config/validatebefore writing it, then correct it. The last valid table keeps serving meanwhile, and a fixed file loads within a second. - Rule. spec §3.1, §3.2, §12.3.
Routing#
404 no_route on every request#
- Cause. The host the gateway sees is not the public host. Usually the ingress rewrites
Hostand does not sendx-forwarded-host, or it sends it but its address is not in the trusted ranges, so the header is ignored. - Check. The running-policy lines list each route's host; compare them with the host the ingress forwards. Check
GATEWAY_TRUSTED_PROXIES(it replaces the file'strustedProxies). - Fix. Have the ingress send
x-forwarded-host, trust its range, or match the host the ingress sends. - Rule. spec §7.3.
404 no_route on some requests#
- Cause. No route matches the host, path and method. A route that lists
match.methodsdoes not handle the others, and*.example.comdoes not matchexample.comitself. - Check. The request's method and path against the route table, and which route wins by precedence.
- Fix. Add the method, add a fallback route without methods for the same host and path, or add the host.
- Rule. spec §3.1, §3.3.
The wrong route answers#
- Cause. Precedence picks the most specific route: host first, then method, then literal prefix length, segment count and route ID.
- Check. Whether a host-specific route exists; a catch-all that names the method never wins over it.
- Fix. Make the intended route more specific than the one that answers.
- Rule. spec §3.3.
404 not_found on a path that should be forwarded#
- Cause. The path is a gateway endpoint (any method on it other than the endpoint's own) or is listed in
reservedPaths; neither is ever forwarded.GET /metricsalso answers404whileGATEWAY_METRICS_TOKENis unset. - Fix. Serve the upstream under another path, or set the metrics token.
- Rule. spec §2.2.
Authentication#
401 unauthorized#
- Cause. The route's mode found no valid credential. In
anymode a bad bearer token is refused even when a valid session cookie was sent, and anAuthorizationheader that is not a bearer token is refused. - Check. For a bearer token: signature, issuer, audience (
GATEWAY_OIDC_AUDIENCE, the client ID by default), anexpclaim, aniatclaim whileGATEWAY_OIDC_MAX_TOKEN_AGE_MSis on, a lifetime within that bound, nononceclaim (an ID token), and the claims inGATEWAY_OIDC_REQUIRED_TOKEN_CLAIMS. For a session: idle lifetime and maximum age, and whether the session store answers. - Fix. Send an access token, not an ID token; ask the provider for a shorter lifetime; sign in again.
- Rule. spec §5.1, §5.3, §5.4.
The browser loops back to the login page#
- Cause. The session is created but not found on the next request. Common reasons: replicas keep sessions in memory, so another replica does not know the session; the cookie is
Securewhile the browser reaches the gateway over plain http; or the session reached its maximum age. - Check.
GATEWAY_SESSION_URLnames a shared Redis or Postgres server on every replica;GATEWAY_COOKIE_SECUREandGATEWAY_EXTERNAL_PROTOmatch how browsers reach the gateway;GATEWAY_SESSION_MAX_AGE_MS. - Fix. Share the session store, serve the gateway over https (or turn
GATEWAY_COOKIE_SECUREoff outside production), and keepGATEWAY_SESSION_SECRETidentical on every replica. - Rule. spec §5.2, §5.3, §5.5.
The login callback answers 400 login_failed#
- Cause. The state cookie is missing or does not match (a second use of the callback, another replica with a different
GATEWAY_SESSION_SECRET, a restart without a secret set), or the code exchange failed. - Check. The log line of the failed login; whether
GATEWAY_OIDC_REDIRECT_URIis the URL the gateway is reached under and is registered with the provider. - Fix. Set the same session secret on every replica and correct the redirect URI, then start the login again from
/auth/login. - Rule. spec §5.2.
Logins fail with 503#
- Cause.
/auth/loginanswers503 identity_provider_unavailablewhen the provider cannot be reached; the callback answers503 session_store_unavailablewhen the session cannot be stored. - Fix. Restore the provider or the shared state. Existing sessions keep working while only the provider is down.
- Rule. spec §5.2.
429 on /auth/login#
- Cause. The login limiter allows
GATEWAY_LOGIN_RATE_LIMIT_MAXattempts (30 by default) per caller address perGATEWAY_LOGIN_RATE_LIMIT_WINDOW_MS. - Check. Whether every caller shares one address because the ingress is not trusted (see one budget for everyone).
- Rule. spec §9.
Cross-site guard#
Browser requests get 403 cross_site_request#
- Cause. The request carries the session cookie and its
OriginorSec-Fetch-Siteis not allowed. When it happens on the gateway's own site, the gateway computes its own origin from the wrong host or protocol. - Check.
gateway_cross_site_refusals_total{route}; whether the ingress sendsx-forwarded-hostandx-forwarded-protofrom a trusted range;GATEWAY_EXTERNAL_PROTO. - Fix. Forward the public host and protocol, or add the calling site to
GATEWAY_ALLOWED_ORIGINS, or fix the client. - Rule. spec §7.3, §8.
Deep links from other sites get 403#
- Cause. Cross-site navigations are refused by
GATEWAY_CSRF_STRICT=trueor by the route'scrossSite: "strict". - Fix. Set
crossSite: "navigation"on routes whose upstream does not change state onGET. - Rule. spec §8.
A WebSocket handshake gets 403 cross_site_request#
- Cause. The handshake carries the session cookie and names no origin, or a foreign one.
GATEWAY_CSRF_STRICTdoes not change this. - Fix. Open the socket from an allowed origin, or authenticate the client with a bearer token instead of the cookie.
- Rule. spec §10.
Logout links from other sites do nothing#
- Cause.
GET /auth/logoutfrom another site is refused with403 cross_site_request, so a foreign page cannot sign a user out. - Fix. Link to logout from the gateway's own site.
- Rule. spec §8.
Workspace access#
403 workspace_forbidden#
- Cause. Access answered with an explicit denial. The gateway never sends Access's reason to the caller.
- Check.
gateway_auth_failures_total{reason}(missing_roleoraccess_denied), the gateway's refusal log line (subject and workspace), and Access's own log for the reason. If roles are missing, check the claim named byGATEWAY_OIDC_ROLES_CLAIM. - Fix. Grant the caller the workspace or role in Access. A new grant can take up to the decision cache lifetime (
GATEWAY_ACCESS_CACHE_TTL_MS, 5 s) to apply;DELETE /v1/decisionspurges it at once. - Rule. spec §6, §6.5.
400 bad_request on a workspace route#
- Cause. The workspace placeholder is missing from the path, or is not a UUID while the route sets
requireUuid. - Fix. Call the route with a UUID in the placeholder, or drop
requireUuid. - Rule. spec §2.3.
503 access_unavailable#
- Cause. Access could not be asked, or its answer could not be read: a timeout (
GATEWAY_ACCESS_TIMEOUT_MS, 3 s), a connection failure, any status other than a decision (including401for a wrong token and404for a wrong path), a non-JSON body, or anallowthat is not a boolean. While the breaker is open the answer comes at once, without calling Access. - Check.
gateway_access_unavailable_total{route},gateway_access_duration_seconds, andGATEWAY_ACCESS_URLandGATEWAY_ACCESS_TOKEN. Routes that need no decision keep working. - Fix. Restore Access. To keep traffic flowing through a short outage, give the route an
accessStaleGraceMs; this delays revocation by as much. - Rule. spec §6, §6.1, §6.2, §6.4.
gateway_access_stale_total grows#
- Cause. Access is unreachable and routes with an
accessStaleGraceMsare serving the last allowing decision, so revocations on those routes are late. - Check.
gateway_access_stale_age_seconds{route}shows how old the served decisions are. - Fix. Restore Access. Set
accessStaleGraceMs: 0on routes where a late revocation is unacceptable. - Rule. spec §6.2.
A revoked caller keeps access#
- Cause. Revocation waits for the cached decision to expire, plus the outage grace when Access is down. An open WebSocket is rechecked only at the revalidation interval, and a bearer token stays valid until it expires.
- Check.
GATEWAY_ACCESS_CACHE_TTL_MSor the route'saccessCacheTtlMs,accessStaleGraceMs,GATEWAY_WS_REVALIDATE_INTERVAL_MSandGATEWAY_OIDC_MAX_TOKEN_AGE_MS. - Fix. Purge with
DELETE /v1/decisions, revoke sessions withDELETE /v1/sessionsorbun run sessions(revoking sessions), and setaccessCacheTtlMs: 0on sensitive routes. - Rule. spec §6, §6.2, §10.
Load and limits#
503 gateway_overloaded#
- Cause. Admission is full (
GATEWAY_MAX_PENDING_REQUESTS) or the exchange cap is full (GATEWAY_MAX_CONCURRENT_REQUESTS). A full admission is usually a slow dependency: requests wait on Access or the shared state and pile up. - Check.
gateway_active_requests; admission refusals are counted on routeunmatchedwith status503, exchange-cap refusals on the matched route. Then check Access latency andgateway_shared_state_errors_total. - Fix. Repair the slow dependency first. Raise the caps only when pod memory allows it (capacity).
- Rule. spec §2.4, §11.2.
429 too_many_requests#
- Cause. A budget refused the request: deployment, caller across routes, caller on the route, or subject on the route. The response carries
retry-after. - Check.
gateway_rate_limited_totaland the configured limits. - Fix. Raise the budget that refuses, or slow the client down until
retry-afterpasses. - Rule. spec §9.
Every caller shares one budget#
- Cause. The ingress is not in the trusted ranges, so the gateway sees the ingress address as every caller's address. With too few
GATEWAY_TRUSTED_PROXY_HOPS, it takes a proxy's entry instead. - Check.
GATEWAY_TRUSTED_PROXIESandGATEWAY_TRUSTED_PROXY_HOPSagainst the number of proxies in front (2for a load balancer plus an ingress). - Fix. Trust exactly the proxy ranges and set the hop count to the number of proxies.
- Rule. spec §7.3.
Limits look per replica#
- Cause. The shared state is unreachable and
GATEWAY_RATE_LIMIT_FALLBACK=localcounts per process, or the deployment runs with in-process state. Withclosed, every request is refused instead. - Check.
gateway_rate_limit_fallback_total{scope}andgateway_shared_state_errors_total. - Fix. Restore the shared state; name a shared server in
GATEWAY_SHARED_STATE_URL. - Rule. spec §9, §12.5.
The shared state is down#
- Cause. Redis or Postgres is unreachable. Callers that need a session are denied (
401or503, and readiness answers503), decisions are asked again instead of read from the cache, and limits followGATEWAY_RATE_LIMIT_FALLBACK. - Check.
gateway_shared_state_errors_total, the/readyzbody, and the once-per-outage log line. - Fix. Restore the server; the gateway reconnects in the background and logs the recovery.
- Rule. spec §12.5.
413 payload_too_large#
- Cause. The body exceeds the route's limit (
maxBodyBytes, thendefaults.maxBodyBytes, thenGATEWAY_MAX_BODY_BYTES, 10 MiB by default). - Fix. Raise the route's limit, and keep the ingress body limit at least as large.
- Rule. spec §11.2.
Upstreams#
502 bad_gateway#
- Cause. The upstream refused the connection or is unreachable, rejected the gateway's client certificate, failed the gateway's certificate check, closed after
103 Early Hintswithout a final response, or answered101on the HTTP path. For a WebSocket route, the upstream refused the handshake or is awssupstream (reasontls). - Check. The log line of the request (it names the TLS reason),
gateway_upstream_failures_totalandgateway_websocket_refusals_total{reason}. - Fix. Repair the upstream, or correct
GATEWAY_UPSTREAM_TLS_CAand the client certificate. Serve WebSocket upstreams over plainws, behind awssproxy if needed. With several upstreams, resendable requests move on to the next one. - Rule. spec §11.1.2, §11.3, §11.4.
504 gateway_timeout#
- Cause. The upstream did not send response headers within
responseMs(5 s by default) after the upload ended. All attempts on a multi-upstream route share that budget. - Check.
gateway_upstream_duration_seconds{route}. - Fix. Speed up the upstream or raise the route's
timeouts.responseMs. - Rule. spec §11.2, §11.3.
Responses or event streams are cut off#
- Cause. A failure after the response headers ends the body instead of producing an error: a
firstByteMstimeout (30 s), abodyMsidle gap (300 s), an upstream that answered before reading the whole upload, or an ingress timeout. - Check. The request's log line records the outcome
aborted; the ingress read timeout and buffering. - Fix. Raise the route's
firstByteMsorbodyMs, and configure the ingress as in Operations: ingress. - Rule. spec §11.2, §11.3.
499 in the metrics#
- Cause. The caller disconnected before response headers were sent; the gateway cancelled the upstream request. Nothing reaches the client.
- Rule. spec §11.3.
500 internal_error#
- Cause. The caller's subject is empty, holds a control character or is longer than 512 characters, so it cannot be written to
x-gateway-user. The gateway logs the route. - Fix. Correct the subject at the identity provider; the gateway never rewrites it.
- Rule. spec §7.1.
The upstream refuses the identity signature#
- Cause. Replicas sign with different keys, a non-production replica signs with a per-process key because
GATEWAY_IDENTITY_SIGNING_KEYis unset (it logs a warning), the upstream rebuilds the payload from other values (host, rewritten path, query, sorted roles), or it still verifiesv3orv4. - Check.
bun run identity:key --publicon each replica gives the same public key; the upstream's rejection reason from the reference verifier. - Fix. Share one key, and follow Upstream identity for the payload.
- Rule. spec §7.4.
WebSockets#
Upgrades get 400 websocket_not_allowed#
- Cause. The route does not set
websocket: true. - Rule. spec §10.
Upgrades get 503 too_many_connections#
- Cause. The replica holds
GATEWAY_WS_MAX_CONNECTIONSopen connections (1000 by default). - Fix. Add replicas, or raise the limit within pod memory (capacity).
- Rule. spec §10.
Connections close unexpectedly#
| Close | Cause |
|---|---|
1008 | Revalidation failed or could not run: the session expired, access was revoked, or the route changed or stopped accepting WebSockets. Expected; investigate only when it is not. |
1001 | The replica is draining (shutdown or rollout). |
| Other | The client did not read (GATEWAY_WS_BACKPRESSURE_LIMIT_BYTES), did not answer a ping (GATEWAY_WS_PONG_TIMEOUT_MS), was idle (GATEWAY_WS_IDLE_TIMEOUT_SECONDS), or sent a message over GATEWAY_WS_MAX_PAYLOAD_BYTES. |
Control plane#
| Status | Cause | Fix |
|---|---|---|
401 | No token, or an unknown token. | Send a token from the map. |
403 | The token's scope does not cover the endpoint. | operate purges, config writes the table; neither does the other's job. |
409 | If-Match is stale, or the write changes a protected route. | Read the active revision again; leave GATEWAY_PROTECTED_ROUTES routes unchanged. |
422 | The table is invalid. | Fix the errors POST /v1/config/validate lists. |
429 | Five failed authentications in sixty seconds from this address. | Wait, and fix the client's token. |
503 | The shared state is unreachable. | Restore it; the control plane refuses every request meanwhile. |
Rules: spec §12.7.
Metrics endpoint#
GET /metrics answers 404 while GATEWAY_METRICS_TOKEN is unset and 401 when the scraper's bearer token does not match it. Rule: spec §12.1.