Skip to content

HTTP status codes you will actually run into, and what to do about them

By · HTTP & Web · 7 min read · Published

There are more than sixty registered HTTP status codes, but a dozen or so account for nearly every failed request you will debug. Each points to a short list of likely causes, so the code tells you where to look before you read a single log line. This guide is written from the client and operator side: you sent a request, something came back, now what? (If you are designing an API and choosing which codes to return, read choosing the right HTTP status code instead.)

First, see the real response

Browsers, HTTP clients and frameworks hide things: redirects are followed silently, error bodies are swallowed, and a CORS failure looks like a network error. Before guessing, look at the raw exchange:

curl -sv https://api.example.com/orders/42 -H "Authorization: Bearer $TOKEN" -o /dev/null
curl -sI https://example.com/old-page          # headers only, without following redirects
curl -sL -w '%{http_code} %{url_effective}\n' -o /dev/null https://example.com/old-page

The verbose output shows the status line, every response header and, crucially, who answered. A server: nginx, server: cloudflare or x-amzn-trace-id header with no sign of your application means a proxy produced the response, not your code. The API Request Builder shows the same details in the browser and generates the curl command for you.

Redirects: 301, 302, 307, 308

Redirects are usually invisible until they break something. Three real problems:

  • POST becomes GET. For historical reasons, clients follow a 301 or 302 after a POST with a GET and drop the body. A common way to hit this is an http:// to https:// redirect, or a missing trailing slash redirect, in front of an API. Your POST arrives as a body-less GET, and the API answers 405 or 404. In a quick test with Node's fetch, a POST to a 301 arrived as GET, while a 307 arrived as POST with the body intact. Fix the URL in the client rather than relying on redirects, and use 307 or 308 when you must redirect API calls.
  • Authorization disappears. Browsers' fetch, curl and most libraries strip the Authorization header when a redirect goes to a different host, so credentials do not leak to a third party. If your API redirects to a CDN or another domain, the follow-up request is anonymous and gets a 401.
  • Redirect loops. "Too many redirects" usually means a proxy terminates TLS and talks plain HTTP to the app, and the app keeps redirecting to HTTPS. Configure the app to trust X-Forwarded-Proto from the proxy.

304 Not Modified is not an error. The browser sent If-None-Match or If-Modified-Since and its cached copy is still valid. If you see stale content, look at Cache-Control and ETag, not at the 304.

400 Bad Request

The server could not parse or accept the request. Read the response body; good APIs say which field is wrong. Frequent causes are malformed JSON (see JSON syntax errors), a query parameter in the wrong format, and a request that is too big in a way the server does not report precisely. nginx answers 400 Request Header Or Cookie Too Large when cookies have piled up on a domain; clearing cookies "fixes" it for one user, and shrinking what you store in cookies fixes it for everyone.

401 Unauthorized and 403 Forbidden

401 means "I do not know who you are": the credentials are missing, expired or invalid. Check that the header is actually sent (a redirect may have stripped it), that it reads Authorization: Bearer <token> with the right scheme, and that the token has not expired. For JWTs, decode it and check exp, aud and iss, as the JWT guide describes. The WWW-Authenticate response header often names the exact reason, for example error="invalid_token", error_description="The token expired".

403 means "I know who you are, and the answer is no". Logging in again will not help; the account lacks a role or scope, or a policy blocks the request. Two non-obvious sources: a web application firewall or CDN rule (look for the vendor in the response headers or an HTML block page), and Amazon S3, which returns 403 instead of 404 for a missing object when the caller lacks permission to list the bucket.

404 Not Found and 405 Method Not Allowed

Before assuming the resource is gone, check the path itself: a missing API version prefix (/v1), a trailing slash the router treats as a different route, a proxy or ingress that strips or adds a path prefix, or the wrong environment's base URL. Some APIs also return 404 rather than 403 for resources you may not see, so the ID could be valid but belong to another account.

A 405 says the path exists but not with this method. The Allow header lists the methods it supports. If you sent a POST and the server logged a GET, look for a redirect in between.

413, 415 and 422: the request body

  • 413 Content Too Large almost always comes from a proxy, not your app. nginx's client_max_body_size defaults to 1 MB; ingress-nginx sets it with the nginx.ingress.kubernetes.io/proxy-body-size annotation. For large files, upload directly to object storage with a pre-signed URL.
  • 415 Unsupported Media Type usually means a missing Content-Type header. fetch(url, { method: 'POST', body: JSON.stringify(data) }) sends text/plain;charset=UTF-8. Add headers: { 'Content-Type': 'application/json' }.
  • 422 Unprocessable Content: the JSON parsed, but the values failed validation. The body should list the fields. Many APIs use 400 for the same thing.

429 Too Many Requests

You hit a rate limit. Slow down, and honour Retry-After, which may be a number of seconds or an HTTP date. Retrying immediately in a tight loop makes it worse, and some providers lengthen the ban when you do. Many APIs also send RateLimit-Remaining-style headers, so you can throttle before you hit the limit. A retry helper that handles 429 and transient 5xx responses:

async function fetchWithRetry(url, options = {}, { retries = 4, baseMs = 500 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, options);
    const retryable = [429, 502, 503, 504].includes(res.status);
    if (!retryable || attempt === retries) return res;
    const retryAfter = res.headers.get('retry-after');
    let wait = baseMs * 2 ** attempt * (0.5 + Math.random());   // exponential backoff with jitter
    if (retryAfter) wait = /^\d+$/.test(retryAfter)
      ? Number(retryAfter) * 1000                                // "Retry-After: 120"
      : Date.parse(retryAfter) - Date.now();                     // "Retry-After: <HTTP date>"
    await new Promise((r) => setTimeout(r, Math.min(Math.max(wait, 0), 30_000)));
  }
}

Against a test server answering 429, then 503, then 200, it returned the 200 after three attempts, honouring both Retry-After formats. Only retry requests that are safe to repeat. A POST that creates an order should carry an idempotency key, which payment APIs such as Stripe support, or it should not be retried automatically.

500 Internal Server Error

The application threw an exception it did not handle. There is nothing to fix on the client side except perhaps the input that triggered it. Find the request in the server logs, ideally by a request ID returned in a response header such as x-request-id, and include that ID in bug reports. An API that returns 500 for bad input is itself a bug; it should be a 400.

502, 503 and 504: the proxy is talking

These three almost always come from a load balancer, reverse proxy, ingress controller or CDN, describing what happened when it talked to your app:

CodeWhat the proxy sawUsual causes
502 Bad GatewayAn invalid response, or the connection was refused or resetApp crashed or restarting; app listening on the wrong port or only on 127.0.0.1 inside a container; keep-alive timeout in the app shorter than the proxy's
503 Service UnavailableNo healthy backend to send toAll instances failing health checks; Kubernetes Service with no ready endpoints; deliberate maintenance or load shedding
504 Gateway TimeoutThe backend accepted the request but did not answer in timeSlow query or downstream call; proxy timeout lower than the work takes (nginx proxy_read_timeout and AWS ALB idle timeout both default to 60 s)

So read the proxy's logs and your app's health, not just your app's logs. The app may never have seen the request. Intermittent 502s during deployments mean old instances are killed before they stop receiving traffic. Add a readiness probe and graceful shutdown, as in the Kubernetes probes guide. For long-running work, return 202 with a job URL instead of raising the timeout forever.

Two non-standard codes appear in logs too: nginx logs 499 when the client gave up and closed the connection before a response, often a sign of a slow endpoint. Cloudflare uses 520-526 for problems reaching your origin, such as 524 when the origin took longer than 100 seconds.

"Status 0" and failed fetches

In a browser, a request blocked by CORS, by a mixed-content rule, by an ad blocker or by a DNS failure has no status at all: fetch rejects with TypeError: Failed to fetch, and older XHR code sees status 0. The server may have answered with a 200; the browser simply will not show you. Open the Network tab and the console, which name the actual reason, and check the Access-Control-Allow-Origin header on the response.

For everything else, the HTTP Status Codes reference explains every registered code, including the rare ones.

More HTTP & Web guides