Debugginghttp502503504gateway

HTTP Debugging: 502, 503 and 504 Are Different Failures

The three gateway errors usually describe three different upstream situations — 502 the backend answered wrongly or closed, 503 no backend was available, 504 the backend did not answer in time — and the debugging procedure is to find which hop generated the status, correlate with that hop’s upstream logs and health state, and check that the timeout ladder is ordered.

ConceptualLinux
Interview question
Progress

The problem

The status page shows 502s at 0.3%, a spike of 504s at 09:00 and a solid wall of 503 for two minutes overnight. Same load balancer, same backends, three different stories. What generated each status, and what was it trying to tell you?

Three codes, three (usual) meanings

A 5xx from a gateway is a proxy, load balancer or CDN reporting on its upstream — the next hop toward your application. The three codes map, in common practice, to three different upstream events. 502 Bad Gateway: the gateway got a response it could not use — the upstream closed the connection before or during the response (a crashed or restarting process, a keep-alive race, an out-of-memory kill), sent malformed headers, or reset the connection. 503 Service Unavailable: the gateway did not have a usable upstream to send to — every backend failed its health check, the pool was empty during a deploy, an overload circuit tripped, or the application itself returned 503 for maintenance or shed load. 504 Gateway Timeout: the gateway sent the request and waited the configured upstream timeout without getting a complete response — a slow database query, a lock, a thread pool that is full, an event loop blocked.

That mapping is a convention, not a law, and infrastructure behaviour varies by proxy, load balancer and CDN. nginx returns 502 when it has no live upstreams; an AWS ALB returns 503 for a target group with no healthy targets; some CDNs return 52x codes of their own for origin failures; Envoy annotates with response flags. Read your gateway’s documentation once and pin the mapping to a wiki page — then the codes become evidence rather than folklore.

Gateway 5xx — the usual upstream event (varies by proxy/LB/CDN)
StatusUsual upstream eventTypical root causesFirst check
502 Bad GatewayUpstream connected but the response was unusable: closed early, RST, malformedBackend crashed/restarting, OOM-killed, keep-alive idle race, header too large, wrong protocol on the portGateway error log: "upstream prematurely closed", "connection reset by peer"; backend restarts
503 Service UnavailableNo usable upstream, or upstream said soAll health checks failing, empty pool mid-deploy, overload shedding, maintenance mode, rate-limit circuitHealth-check state of the pool; deployment timeline; Retry-After header
504 Gateway TimeoutUpstream accepted but did not finish within the gateway’s timeoutSlow DB query, lock contention, exhausted worker pool, blocked event loop, an even slower downstreamGateway’s upstream_response_time vs its timeout; backend latency and DB slow-query log

Which hop generated the status?

A request may pass CDN → load balancer → ingress → sidecar → application, and any of them can generate a 5xx. Before any other question, identify the author. The Server header often names it (nginx, awselb/2.0, cloudflare, envoy). Via lists proxies that added themselves. The body is a fingerprint: nginx’s default 502 page, the ALB’s bare-text 502 Bad Gateway, a CDN’s branded error page, or your application’s JSON error envelope. CDN-specific headers (cf-ray, x-cache, x-served-by) tell you the CDN was reached; a request id header (x-request-id, x-amzn-requestid, x-amz-cf-id) is the thread you pull through every log downstream.

Timing is the second fingerprint. A 504 that arrives at exactly 60.0 s was generated by something with a 60 s timeout — nginx’s proxy_read_timeout default, the ALB’s default idle timeout — and the number identifies the hop. A 502 that arrives in 3 ms never reached a backend; one that arrives after 30 s of streaming did.

  • Server, Via, body format, CDN headers, request id → the hop that wrote the status.
  • Time-to-error equal to a known timeout → the hop that owns that timeout.
  • Application-generated 503 (your code, on purpose) vs gateway-generated 503 (no backends) look the same in a dashboard; the body and Server header separate them.

The procedure

Linux

Take the request id from one failed response and search the gateway’s access log for it: the line includes the upstream address chosen, upstream_status, upstream_response_time and upstream_connect_time (nginx variable names; ALB and Envoy have equivalents). That single line tells you whether an upstream was chosen, whether it connected, how long it took, and what it returned. Then the gateway’s error log for the same second: upstream prematurely closed connection while reading response header from upstream (502), connect() failed (111: Connection refused) while connecting to upstream (502 — nothing listening, see TCP Debugging: Reading the Handshake on the Wire), no live upstreams while connecting to upstream (502 in nginx; a 503 elsewhere), upstream timed out (110: Connection timed out) while reading response header from upstream (504).

Cross-check the health-check state at that moment — the load balancer’s target health history, the ingress controller’s endpoint list, kubectl get endpoints — because a 503 wall coincides almost always with a health check flipping. Then the backend: was it restarting (deploy, crash loop, OOM)? Was it slow (p99 latency, DB slow-query log, lock waits)? Load Balancers: L4 vs L7 explains how health checks decide pool membership and why a check that passes on / and fails on /health matters.

curl -sv -w timings from a machine that can reach both the gateway and the backend directly reproduce the split: if the backend answers directly in 40 ms but through the gateway in 60 s then 504, the gateway is not reaching the backend it thinks it is (wrong upstream address, security group between LB and instance, a stale endpoint).

nginx access + error log lines for the three statuses (log_format with upstream_* variables)
# 502: upstream closed the connection while nginx was reading the response header
10:14:02 GET /api/orders 502 rid=7c1a upstream=10.0.3.11:8080 up_status=502 up_connect=0.001 up_resp=0.004
10:14:02 [error] upstream prematurely closed connection while reading response header from upstream, client: 198.51.100.23, upstream: "http://10.0.3.11:8080/api/orders"

# 503 elsewhere (ALB / Envoy) for no healthy targets; nginx reports the same condition as 502:
10:31:55 [error] no live upstreams while connecting to upstream, client: 198.51.100.23, upstream: "http://orders-pool/api/orders"

# 504: nginx waited proxy_read_timeout (60 s) for the first byte and gave up
09:00:41 GET /api/reports 504 rid=9be0 upstream=10.0.3.12:8080 up_status=504 up_connect=0.001 up_resp=60.001
09:00:41 [error] upstream timed out (110: Connection timed out) while reading response header from upstream

The keep-alive race: the 502 that happens 0.3% of the time

Gateways keep idle connections to backends open and reuse them. The backend also has an idle timeout, after which it closes the connection. If the backend’s idle timeout is shorter than the gateway’s, there is a window in which the gateway picks an idle connection at the same instant the backend is closing it, sends the request into a closing socket, and gets a FIN or RST back — a 502 with upstream prematurely closed connection. Node’s default server.keepAliveTimeout is 5 s; an ALB’s default idle timeout is 60 s; the race fires on a small fraction of requests and looks like a flaky backend that is perfectly healthy.

The rule is: the backend’s keep-alive idle timeout must be longer than the gateway’s (and the backend’s headersTimeout longer still), so the gateway is always the one to close. Set Node to 65 s behind a 60 s ALB, or nginx’s keepalive_timeout above whatever fronts it; and configure the gateway to retry idempotent requests on a connection that failed before any bytes were received. Keep-Alive and Connection Reuse and Connection Pooling cover the mechanism; the 502 challenge in this module is the crash-loop variant.

  • Backend idle timeout > gateway idle timeout, always. Node: server.keepAliveTimeout = 65000; server.headersTimeout = 66000 behind a 60 s LB.
  • Sporadic 502s at low rate with a healthy backend and "prematurely closed" in the log = this race.

The timeout ladder must be ordered

Every hop has a timeout, and they must decrease as you go inward: client timeout > CDN timeout > load balancer timeout > application request timeout > database statement timeout. When the order is broken — a 30 s client with a 60 s gateway with a 120 s query — the client gives up and retries while the gateway is still waiting and the database is still working. The retry starts a second query; the client gives up again; the database now has three copies of the same slow query competing for the same lock. That is how a single slow endpoint becomes an outage: the retries amplify load exactly when capacity is gone.

Ordered timeouts fail inward-first: the statement timeout kills the query at 20 s, the application returns a clean 500 or 503 at 21 s, the gateway forwards it at 21 s, the client sees an honest error at 21 s and does not retry a request that is still running. The 504 disappears not because the backend got faster but because the failure is reported by the layer that owns it. Pair this with a retry budget and Retry-After on 503 so clients back off rather than pile on. The 504 challenge in this module walks a mis-ordered ladder.

Timeouts must shrink inward so the innermost layer fails first (illustrative numbers)
  1. Client / browser: 30 slongest; retries only if it never got a response
  2. CDN / edge: 25 spasses through 5xx; returns its own on origin timeout
  3. Load balancer / ingress: 20 s`proxy_read_timeout`, ALB idle timeout
  4. Application request: 15 sserver-side deadline; cancels work and returns 503/500
  5. Database statement: 10 s`statement_timeout`; the first thing to give up

Key points

  • 502 = upstream answered badly or closed; 503 = no usable upstream (or the app said so); 504 = upstream did not finish in time — usual meanings, varying by proxy, LB and CDN.
  • First find the hop that generated the status: Server/Via headers, body fingerprint, CDN headers, request id, and the time-to-error.
  • The gateway’s access log has upstream_status and upstream_response_time; its error log names the cause verbatim.
  • A 503 wall almost always coincides with health checks flipping; check the pool’s health history first.
  • Low-rate 502s on a healthy backend are usually the keep-alive race; backend idle timeout must exceed the gateway’s.
  • Order the timeouts to shrink inward, or retries amplify a slow endpoint into an outage.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why three different gateway codes?

So the client can react differently: 503 with Retry-After is "come back later", 504 is "your request may have run — be careful retrying non-idempotent work", 502 is "something between us is broken". Collapsing them destroys that signal.

Why does the gateway have a timeout at all?

Without one, a hung backend holds a gateway connection and a client forever, and the gateway’s connection budget drains until it can serve nobody. The timeout converts one slow request into one failed request instead of a cascading stall.

Why must the backend close last in keep-alive?

Only the side that sends a request can lose it to a closing socket. If the gateway is always the closer, it never sends into a socket the other side is shutting.

How it fails

What the failure looks like from inside real software.

  • Treating 502/503/504 as one "server error" metric and paging on the sum, losing the difference between a crash loop, a health-check flip and a slow query.
  • Debugging a 503 in the application when it was generated by the load balancer because the health-check path started returning 401.
  • Node behind an ALB with default keep-alive timeouts, producing 502s at 0.2% that are blamed on "network flakiness" for months.
  • A 30 s client timeout in front of a 60 s gateway and a 120 s query: every slow request runs three times.
  • Reading nginx’s 502 for "no live upstreams" as a backend bug when it is the nginx equivalent of the ALB’s 503.
  • No request id propagated end to end, so a single failure cannot be found in the backend log among thousands of successes.