HTTPPROTOCOL-SPECIFICGENERALFRAMEWORK-SPECIFIC

Status Codes From the Server's Side

A status code is an operational signal: it decides who gets paged, what retries, and whether the number is counted against you.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

Which status code should this failure return, given that the code decides retries, alerts and blame?

The requirement

Callers need to know what happened and what to do next; operators need to know whether it was our fault. One three-digit number carries both.

The obvious build

Return 200 with {"success": false, "error": "..."} so clients have one parsing path, or return 500 for anything that went wrong.

Why it breaks

Every failure is a 200, so your error rate is zero, your alerting never fires, and the outage is reported by a customer (The Metrics a Backend Must Emit).

How it breaks in production
  • Every failure is a 200, so your error rate is zero, your alerting never fires, and the outage is reported by a customer (The Metrics a Backend Must Emit).
  • Client-caused failures returned as 500 pollute your error budget and page an engineer for a caller sending malformed JSON.
  • A client library retries on 5xx. Returning 500 for "this order is already cancelled" produces a retry storm against a request that can never succeed (Retry Storms).
  • A load balancer or service mesh makes routing and health decisions from status codes. Systematic 5xx from one instance can pull it out of rotation — which is correct when it is broken and wrong when the caller is.
  • A proxy caches a 200 body that says "error", serving the failure to everyone until it expires.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • The first digit is the operational one: 2xx it worked, 3xx go elsewhere, 4xx the caller must change something, 5xx the server must. Everything downstream — retries, alerts, dashboards, error budgets — keys off that digit.
  • 4xx versus 5xx is an attribution decision, and it is the one that decides whether a human is woken. Getting it wrong is not a style problem.
  • 429 and 503 are the two codes that mean "not now, try later", and both take Retry-After. Choosing them instead of 500 is what lets a well-behaved client back off instead of hammering (Backoff and Jitter).
  • 202 Accepted means "I have taken responsibility for this, it is not finished". It is the honest answer for work that continues after the response (Background Jobs).
  • The status is committed the moment headers are written. A failure discovered while streaming a 200 cannot be turned into a 500 (Request and Response Objects).
  • Some codes exist between you and the client and never come from your code: a 502 or 504 from a proxy, a 413 from an edge body limit, a 499-style client-cancelled entry in an Nginx log.

One question decides the digit

Before reaching for a specific code, answer the attribution question: could the caller have avoided this by sending something different? Yes means 4xx. No means 5xx. That single question resolves most arguments, and it is the one that decides whether an alert fires.

The uncomfortable cases are the interesting ones. A caller requesting a report so large it exhausts memory is arguably both — and the useful resolution is to make it a 4xx by defining a limit, so the caller has something they can change (Pagination That Survives a Large Table).

Choosing the code

What is the caller supposed to do next?

400 / 422 — fix the request

when Malformed syntax (400) or well-formed but semantically rejected (422).

cost The caller needs a stable error body to know which field; the status alone is not actionable (Reporting Validation Failures).

401 — authenticate

when No credential, or a credential that failed verification.

cost Clients will retry after refreshing a token; a 401 loop is a common client bug.

403 — you cannot, ever, as you

when Authenticated, identified, and not permitted.

cost Confirms the resource exists. Use 404 instead when existence itself is sensitive.

404 — not found, or not yours

when No such resource, or hiding existence deliberately.

cost Ambiguity: your own debugging loses the same information the attacker does.

409 — state conflict

when The request contradicts current state: a version mismatch, an already-cancelled order.

cost The caller must re-read and decide; that logic has to exist on their side.

429 — too many, slow down

when A rate or quota limit was hit.

cost Discloses the limit. Include Retry-After or clients will busy-retry (Rate Limiting).

202 — accepted, not finished

when Work continues after the response.

cost You now owe the caller a way to find out how it ended (Background Jobs).

500 — our bug

when An unexpected exception; an invariant we broke.

cost Counts against availability, pages someone, and should.

503 — our capacity

when Deliberate shedding, dependency unavailable, shutting down.

cost Counts as unavailability; with Retry-After it is at least a cooperative failure (Graceful Shutdown).

What the code does after it leaves you

The reason to care is that the number is consumed by machines you do not control. A status code is not a message to a developer reading logs — it is an instruction to client libraries, proxies, meshes, caches and your own alerting.

Read this table as the real cost of a miscategorisation. Each row is something that happens automatically, without anyone deciding it.

ConsumerReads the status to decideCost of getting it wrong
Client HTTP libraryWhether to retry, and how many times500 for a permanent rejection produces N times the load on a request that can never succeed
Load balancer / meshWhether the instance is healthy, whether to retry elsewhereSystematic 5xx from caller errors can pull healthy instances from rotation
Circuit breakerWhether to open and stop calling youCaller-caused 5xx trips a breaker and takes you offline for everyone (Circuit Breakers)
Cache / CDNWhether the response is cacheableA 200 carrying an error body can be cached and served to everyone
Your alerting and SLOsWhether this counts against availability4xx as 5xx burns error budget; 5xx as 200 makes an outage invisible
AutoscalerWhether errors indicate saturation503 from shedding is a scale signal; 400 from a broken client is not (Autoscaling a Backend)

The status is the envelope, the body is the detail

A status code has three digits and cannot say which field was wrong. A response body can say everything and is invisible to every intermediary. Using both, with a clear division of labour, is what makes an API debuggable and operable at once.

The stable machine-readable code in the body matters more than the message: messages get rewritten for clarity, and any client that branched on the message string breaks when they do.

Reporting a rejected order
200 with a flag
HTTP/1.1 200 OK
content-type: application/json

{
  "success": false,
  "error": "Insufficient inventory for SKU ABC"
}

// error rate: 0%
// retries: none, the client library saw success
// caches: may store and replay this to others
409 with a structured body
HTTP/1.1 409 Conflict
content-type: application/json
cache-control: no-store

{
  "code": "insufficient_inventory",
  "message": "Only 1 unit of ABC is available.",
  "details": { "sku": "ABC", "requested": 2, "available": 1 },
  "correlationId": "01J8ZK9Q2M"
}

// 4xx: counted as caller error, no page
// no retry: 409 is not retryable by default
// correlationId: this exact request is findable in logs

The status is the only part that intermediaries and automation can read; the body is the only part that can name the field. Collapsing both into a 200 does not simplify the client — it removes the layer that retries, caches, breakers and alerts were already using.

How to build it

Most important first.

  • Choose by attribution first: could the caller have avoided this by sending something different? If yes it is 4xx, however annoyed you are about it.
  • Use the specific 4xx rather than a blanket 400 where it carries operational meaning: 401 unauthenticated, 403 authenticated but not permitted, 404 not found or not visible to you, 409 conflicting state, 422 well-formed but semantically rejected, 429 rate limited (Authentication vs Authorization).
  • Pair every status with a structured, stable error body — a machine-readable code plus a human message plus a correlation id — so the status carries the operational meaning and the body carries the detail (An Error Taxonomy That Maps Cause to Response).
  • Return 503 with Retry-After when shedding load deliberately, so the code means "capacity" and not "bug".
  • Distinguish "a dependency failed" (502 or 503 from you) from "you asked for something impossible" (4xx). Both feel like failure in the handler; they mean opposite things to a caller.
  • Decide once, centrally, in an error boundary that maps error types to statuses, so the mapping is a table rather than a hundred independent judgement calls (Error Boundaries: Three Translations, Not One).

What can go wrong

Failure modes
  • 404 used for both "no such resource" and "you may not see this". That is deliberate and useful for hiding existence — and it makes genuine bugs hard to distinguish from authorization behaviour (Object-Level Authorization).
  • 400 for everything, so the client cannot tell a schema failure from a business-rule rejection and cannot build sensible handling.
  • 500 returned for a caller's bad input, which inflates your error rate and desensitises the alert that matters.
  • 200 with an error body, breaking every intermediary that reasons about status: caches, retry policies, health checks, dashboards.
  • The mitigation failing: a central error map with a default of 500, quietly converting new domain errors into pages until someone notices the pattern.
What can race
  • A 409 is frequently the honest report of a race: two concurrent writes, one lost the version check, and the caller is being told to re-read and retry (Optimistic Concurrency).
  • A request that succeeds server-side but returns an error to the client because the connection dropped after commit is why status codes alone cannot make retries safe (Duplicate Detection).
Security
  • Status codes leak existence. A 403 for an object that exists and a 404 for one that does not tells an unauthenticated prober which ids are real; returning 404 for both is the standard trade (Object-Level Authorization).
  • Distinct codes for "unknown user" and "wrong password" enable user enumeration. Both should be the same response, and ideally with similar timing (Credentials and Password Handling).
  • Do not put internal detail in the body of a 500. "Database connection to orders-primary refused" is a map of your infrastructure (Not Leaking Your Internals).
  • A 429 tells an attacker exactly where the limit is. That is usually an acceptable trade for the honesty a real client needs, but it is a disclosure decision, not a neutral one (Rate Limiting).
Misreads
  • "5xx means the server crashed." It means the server accepts responsibility. A deliberate 503 during load shedding is a healthy service behaving correctly.
  • "4xx is the client's fault, so I can ignore it." A spike in 400s is usually *your* release breaking someone's integration.
  • "Retryable and safe-to-retry are the same." A 503 says trying again may work. Whether trying again is safe is a property of your endpoint, and only idempotency makes it true (Idempotency in Backends).
  • "422 versus 400 is pedantry." It is when nothing consumes the difference. It stops being pedantry the moment a client needs to distinguish "I sent malformed JSON" from "the business rejected this".

Operating it

How you see it in production
  • Graph 4xx and 5xx separately, always. Summed together they are close to meaningless: one is caller behaviour, the other is your availability (The Metrics a Backend Must Emit).
  • Alert on 5xx rate and on specific 4xx *changes*. A sudden jump in 401s is a broken client deploy or a credential-stuffing run; a jump in 422s is usually a contract mismatch after a release.
  • Break status by route and by caller. "5xx is up" is a starting point; "5xx is up on one route for one API key" is a diagnosis.
  • Compare status distribution at the proxy and at the application. Codes present at one and absent at the other are being generated by an intermediary (The Request Lifecycle).
What changes at 10x and 100x
  • At 10x, the 4xx/5xx split stops being a convention and becomes the input to autoscaling, circuit breakers and error budgets. Miscategorised errors now drive automation, not just dashboards (Circuit Breakers).
  • At 100x, deliberate 503 shedding is a design tool: refusing 1% quickly can be how the other 99% stay healthy (Backpressure).
  • Retry behaviour compounds: a code that a client library treats as retryable is a code that arrives three times under load. The status you choose is a load-multiplier decision (Retries).
What this costs
  • 404-for-forbidden protects existence and makes debugging harder for legitimate users and for you.
  • Specific codes are more useful to clients and are also a commitment: changing a 422 into a 409 later is a breaking change for anyone who branched on it (Running Two API Versions in One Service).
  • Returning 503 rather than queueing gives fast, honest failure and counts as unavailability in your own metrics. Queueing hides it as latency instead.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • PROTOCOL-SPECIFICHTTP status semantics. gRPC has its own code set with a different shape — INVALID_ARGUMENT, FAILED_PRECONDITION, UNAVAILABLE — and no 4xx/5xx digit to key automation on, so retry policy there is expressed per code rather than per class.
  • GENERALThe attribution question — is this the caller's to fix or ours? — is protocol-independent and survives any transport you move to.
  • FRAMEWORK-SPECIFICDefault mappings differ and are worth checking: many frameworks turn an unhandled exception into 500 and a validation-library failure into 400 or 422 automatically. Inheriting that default silently is how domain errors end up as 500s.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.