Skip to content

Reading HTTP status codes

The HTTP article introduced the status line and the five classes of response code. This one is about the dozen codes that actually appear in a production log, and specifically about what each one tells you regarding who has the problem — because that's the decision a status code is really being used to make at three in the morning.

The classes, as an ownership question

2xx   It worked
3xx   Look somewhere else
4xx   The client sent something wrong
5xx   The server failed to handle something correct

The line between 4xx and 5xx is the one worth stating precisely, because it decides which team gets paged:

A 4xx says the request was faulty. A 5xx says the request was fine and the server couldn't fulfil it.

Which is why a spike in 5xx is an incident and a spike in 4xx usually isn't — it's more often a client that started sending malformed requests, a scanner probing for paths that don't exist, or an expired credential somewhere. Usually. A 4xx spike that starts exactly when you deployed is your bug wearing a client-error costume, and the timestamp is what tells you which.

The ones you'll actually see

200 OK and 201 Created need no discussion. 204 No Content does: it means success with an empty body, and a client that tries to parse the response as JSON gets a parse error on an entirely successful request. That mismatch is a common source of "the API is broken" reports where the API is fine.

301 versus 302 is the redirect distinction with real consequences. 301 Moved Permanently gets cached by browsers and, in some cases, cached indefinitely — publish a wrong 301 and visitors keep following it long after you've fixed the server, with no way for you to clear their caches. 302 Found is temporary and not cached the same way. When in doubt, use 302; a permanent redirect is a commitment.

There's a subtlety underneath both: historically, clients converted POST to GET when following 301 and 302, dropping the request body. 307 Temporary Redirect and 308 Permanent Redirect exist to forbid that — they preserve the method and body. An API that redirects POST requests should use 307 or 308, or it will silently turn writes into reads.

304 Not Modified isn't an error at all. It's the server telling a client that its cached copy is still current, and the response has no body — which makes it the cheapest possible successful response. A healthy site serves plenty of them, and a monitoring dashboard that counts 304 as a failure is miscounting.

400 Bad Request is the request being malformed. Frequently it's not the application's judgement at all but the web server's: a header too large, a URL too long, a body exceeding client_max_body_size in nginx. If a 400 never reaches your application logs, look at the proxy in front of it.

401 versus 403 trips people constantly:

  • 401 UnauthorizedI don't know who you are. No credentials, or credentials that didn't validate. The name is a historical misnomer; it's about authentication.
  • 403 ForbiddenI know who you are, and you may not do this. Authentication succeeded, authorisation failed.

The debugging consequence is direct. A 401 sends you to the token, the session, or the auth service. A 403 sends you to permissions, roles, or policy. Sending a 403 for a missing token wastes someone's afternoon in the wrong system.

404 Not Found is more interesting than it looks. The route doesn't exist on the server that answered — and in a system with a reverse proxy or an ingress controller, that server may not be the one you think. A 404 for a path you know exists is very often a routing problem: the request reached the wrong backend, and that backend answered honestly about a path it's never heard of.

429 Too Many Requests means rate limiting, and the response should carry a Retry-After header saying how long to wait. A client that retries immediately on 429 makes the situation worse and can turn a rate limit into an outage. This ties directly to the rate limiting article.

The 5xx triad that names the failure for you

500, 502, 503, and 504 are the four codes worth being able to distinguish instantly, because in a load-balanced system each one points at a different component.

500 Internal Server Error — the application ran and threw. It's your code, or something it depends on. The stack trace is in the application log.

502 Bad Gateway — a proxy tried to reach the backend and got an invalid response, or none. The backend crashed, isn't listening, or closed the connection mid-response. Critically, the proxy is working; it's reporting on something else.

2026/08/03 10:47:22 [error] 1204#0: *8821 connect() failed (111: Connection refused)
    while connecting to upstream, client: 203.0.113.9, server: api.example.com,
    request: "GET /health HTTP/1.1", upstream: "http://10.20.0.44:8080/health"

That's nginx's log for a 502, and errno 111 is ECONNREFUSED — the exact connection refusal covered in the troubleshooting module. The backend port has nothing listening. One ss -tlpn on 10.20.0.44 finishes the investigation.

503 Service Unavailable — the server understood and is deliberately declining. Nothing crashed. This is what a load balancer returns when no backend is healthy, what an application returns during a maintenance window, and what a queue-full condition should return. 503 with Retry-After is the polite version of overload.

504 Gateway Timeout — the proxy reached the backend, the backend accepted the connection, and then didn't answer in time. The backend is up and slow — a hung database query, a lock, an external call with no timeout. This is the code that most often means "somewhere further down the chain, something is waiting."

Code Proxy status Backend status Look at
500 Fine Fine, but the app threw Application logs and the exception
502 Fine Down, refusing, or speaking nonsense Is the process running? Is it bound to the right address?
503 Fine No healthy backends, or deliberately declining Health checks, capacity, maintenance flags
504 Fine Up but too slow to answer Slow queries, downstream calls, the proxy's timeout setting

The pattern across all four: 502, 503, and 504 are produced by the thing in front, about the thing behind. They tell you the proxy is healthy, which is genuinely useful information — it eliminates a whole layer.

The non-standard code you'll meet anyway

499 appears in nginx access logs and in no RFC. It means the client closed the connection before nginx could reply — someone hit stop, a mobile app was backgrounded, or, most importantly, an upstream load balancer's timeout fired before nginx's did.

A wave of 499s usually means requests are slower than some client-side timeout. Chasing it as an nginx bug is wasted effort; the fix is upstream latency or a timeout that's shorter than the work it's waiting on.

Investigation: intermittent 502s after a deploy

An API returns 502 for roughly one request in ten, starting right after a rolling deployment. Nine in ten succeed.

The intermittency is the clue. A wholly broken backend produces 100% 502; a partial rate means some backends are bad. Check what the proxy thinks is healthy:

curl -s -o /dev/null -w '%{http_code}\n' http://10.20.0.44:8080/health
curl -s -o /dev/null -w '%{http_code}\n' http://10.20.0.45:8080/health
200
000

%{http_code} prints only the status, which makes this scriptable across a fleet. 000 means curl got no HTTP response at all — the second backend isn't answering. One host in the rolling deployment failed to start, the load balancer is still sending it traffic, and every request that lands there becomes a 502.

Two follow-up questions matter more than the immediate fix. Why did the health check not remove that backend? And why did the deployment continue past a host that failed to come up? The 502 was a symptom; the missing health check is the actual defect, and it will produce a different outage next time.

Practice

  1. Use curl -s -o /dev/null -w '%{http_code} %{time_total}\n' against several sites and endpoints, including one that redirects. Then add -L and explain what changes.
  2. Configure nginx as a proxy to a backend that isn't running, and record the code and log line. Start the backend but make it sleep longer than nginx's proxy_read_timeout, and record those. Match each to the table above.
  3. Find a 429 from a public API (many rate-limit unauthenticated requests quickly) and read its Retry-After header. Write down what a correct client would do.
  4. In an application you work on, find a place returning 403 where 401 would be right, or the reverse. If there isn't one, find where a POST redirect is issued and check whether it uses 302 or 307.

Exercise 2 is the one that makes the 5xx table permanent — once you've deliberately produced a 502 and a 504 from the same proxy and watched the log lines differ, you stop having to look them up.

Status codes are the response's summary. The headers around them carry the machinery: what may be cached and for how long, who the original client was before three proxies rewrote the connection, and whether a browser will let JavaScript read the response at all.

Sources