system-design

Error Handling Contracts

How your API fails is as much a part of its contract as how it succeeds — and it’s the part that determines whether clients can build reliable software on top of you.

Prerequisites: REST, API Design Principles Time to read: ~16 minutes


The problem

Things go wrong. The request is malformed, the user isn’t authorized, the resource doesn’t exist, a downstream service is down, or you’ve hit a bug.

🚨 How you communicate the failure determines whether the client can react correctly. A good error tells the client three things: what went wrong, whose fault it is (so they know whether to retry), and what to do about it. A bad error is 500 with {"error": "something went wrong"} — which tells the client nothing and forces a support ticket.

Error handling is not an afterthought. It’s a contract, and clients build retry logic, user messaging, and monitoring on top of it.


Use HTTP status codes correctly

🚨 The status code is the primary signal, and the whole ecosystem reads it.

The most important rule, restated because it’s the most-violated: 4xx is the client’s fault (don’t retry), 5xx is the server’s fault (safe to retry).REST

400 Bad Request        malformed — fix the request, don't retry
401 Unauthorized       not authenticated — get credentials
403 Forbidden          authenticated but not allowed — don't retry
404 Not Found          no such resource
409 Conflict           state conflict (version mismatch, duplicate)
422 Unprocessable      valid syntax, invalid semantics (business rule)
429 Too Many Requests  rate limited — back off, respect Retry-After
500 Internal Error     we broke — safe to retry
503 Service Unavailable overloaded/maintenance — retry with Retry-After

🚨 The two cardinal sins:

1. 200 with an error in the body. {"success": false, "error": ...} with a 200 status breaks every proxy, cache, load balancer, and monitoring tool — they all see success. A client that checks the status code proceeds as if the operation worked. Your error rate reads 0% while the API is broken.

2. 500 for client errors. A validation failure returned as 500 makes clients retry forever (5xx is retryable), pollutes your error metrics, hides real server failures in the noise, and may trip circuit breakers or page on-call — all for what is actually the client’s bug.


A consistent error body

The status code says the category; the body says the specifics. 🚨 The body must have the same shape for every error across the entire API — clients parse it programmatically.

A good structure (aligned with RFC 9457, Problem Details for HTTP APIs — the standard worth knowing):

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Insufficient funds",
  "status": 422,
  "detail": "Your balance of $40 is less than the transfer amount of $100.",
  "instance": "/transfers/8821",
  "code": "INSUFFICIENT_FUNDS",
  "requestId": "req_7f3a9c2e"
}

What each part does:

🚨 The human message and the machine code are different things. You can freely improve the wording of detail; you can never change code without breaking clients that branch on it. Beginners conflate them and end up with clients parsing English strings, which breaks the moment you fix a typo.


Validation errors: return them all at once

For a form with multiple invalid fields, 🚨 return every error, not just the first. Returning one at a time forces the user through submit-fix-submit-fix-submit, which is a terrible experience and a common API mistake.

{
  "code": "VALIDATION_FAILED",
  "status": 422,
  "title": "Validation failed",
  "errors": [
    {"field": "email", "code": "INVALID_FORMAT", "detail": "Not a valid email address."},
    {"field": "age",   "code": "OUT_OF_RANGE",   "detail": "Must be between 18 and 120."},
    {"field": "phone", "code": "REQUIRED",        "detail": "Phone number is required."}
  ]
}

Field-level codes let the client highlight the specific inputs and localize the messages itself.


What NOT to leak

🚨 Error messages are a security surface. A verbose error is a gift to an attacker.

Never expose in an error response:

OWASP Top 10

The rule: detailed errors in your logs, generic errors to the client. The requestId bridges them — the client gets a safe generic message plus a request ID, and you look up the full detail in your logs.

// To the client  safe
{"code": "INTERNAL_ERROR", "title": "An unexpected error occurred",
 "status": 500, "requestId": "req_7f3a"}
// In your logs  everything, keyed on req_7f3a

Retryability and the client contract

🚨 Tell the client whether and when to retry. This is what makes robust clients possible.

A well-behaved client reads the status class, respects Retry-After, and uses the machine-readable code to decide what to do — none of which is possible if your errors are inconsistent.


Partial success

Some operations partly succeed — a bulk import where 8 of 10 rows are valid, or an aggregation where one downstream service is down.

🚨 A blanket 400 or 500 is wrong here — it hides which parts worked. Options:


GraphQL and gRPC errors

Different styles worth knowing:


⚖️ Trade-offs

Choice Gain Cost
Correct status codes Clients, proxies, monitoring all work Discipline; more codes to get right
Consistent error body Clients parse errors reliably An agreed schema to maintain
Machine-readable codes Clients branch safely; wording is free to change Codes become a permanent contract
All validation errors at once Better UX; fewer round trips Slightly more to implement
Generic client errors Security; no info leak Need requestId + logging to debug
requestId everywhere Every error is traceable Must propagate through the stack

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Break your monitoring. Return 200 with {"error": ...} for a failure. Point a monitoring tool at the endpoint and watch the error rate read 0% while the API is broken. Then fix the status code and watch the error surface. This makes the “200 with an error” sin visceral.

2. Design a consistent error contract. Write the error schema for an API — the standard shape, the machine codes, the validation-error structure. Then apply it to five different failure cases (bad input, unauthorized, not found, conflict, downstream failure) and confirm the shape is identical.

3. Add request IDs end to end. Generate a request ID at the edge, propagate it through your services, include it in every response and every log line. Then simulate an error, take the ID from the response, and find the exact request in your logs. That workflow is what turns “I got an error” into a two-minute investigation.

4. Leak, then stop leaking. Return a raw stack trace or SQL error to the client. Note how much it reveals about your internals. Then replace it with a generic error plus a request ID, keeping the detail in the logs. That’s the secure pattern.


Check yourself

1. Why is returning 200 with an error in the body a serious mistake? Because the entire HTTP ecosystem reads the status line, not your body. Load balancers, reverse proxies, and CDNs treat 200 as success and may cache it; monitoring computes error rates from status codes, so a completely broken API reads as 100% healthy; retry logic and circuit breakers see success and don't react; and any client that checks the status code before parsing the body proceeds as if the operation worked — acting on data that doesn't exist or a change that didn't happen. The status code is the primary, machine-readable signal of success or failure, and overriding it with a body flag breaks every layer that depends on it.
2. Why should clients branch on a machine-readable code, not the error message? Because the human-readable message and the machine code serve different purposes and have different stability guarantees. The message (`"Your balance of $40 is less than..."`) is for humans — you want to freely improve its wording, fix typos, and localize it. The code (`INSUFFICIENT_FUNDS`) is for machines — it's part of your contract, and clients write logic like `if code == "INSUFFICIENT_FUNDS": show top-up prompt`. If clients branch on the *message* instead, then the moment you fix a typo or translate it, their code breaks. Providing a stable machine code lets client behaviour be reliable while you keep the human text flexible — conflating them forces you to choose one or the other.
3. What should and shouldn't appear in an error returned to a client? **Should:** the correct status code, a stable machine-readable error code, a safe human-readable title/detail, field-level errors for validation, retry guidance (`Retry-After`), and a request ID. **Shouldn't:** stack traces (reveal framework, versions, file paths), SQL or internal error text (confirms injection vectors and internal structure), internal hostnames/IPs/service names, and — critically — anything that lets an attacker distinguish cases they shouldn't, like "user not found" vs "wrong password" (enables account enumeration). The pattern is detailed errors in your logs, generic errors to the client, bridged by a request ID: the client gets a safe message plus an ID, and you look up the full detail internally.
4. Why return all validation errors at once instead of the first one? Because returning one error at a time forces the user through a submit-fix-submit-fix-submit cycle: they fix the first error, resubmit, discover the second, fix it, resubmit, discover the third. For a form with several invalid fields that's a frustrating experience and multiple wasted round trips. Returning every validation error in one response — with field-level codes and messages — lets the client highlight all the problematic inputs simultaneously, so the user corrects everything in a single pass, and lets the client localize each field's message independently. It's more work on the server (validate everything rather than short-circuiting on the first failure) but a much better API.
5. Why is the request ID the most operationally valuable field in an error? Because it turns an unactionable report into a precise investigation. When a customer says "I got an error at 3pm," you have almost nothing to go on — but if the error response included a request ID and they can quote it (or it's in their logs), you can look up that exact request in your logs and traces: the full stack trace, the parameters, which service failed, and the whole distributed trace of the request across your services. It's the bridge between the safe, generic error you return to the client and the detailed internal diagnostics you keep private — you get debuggability without leaking internals. That's why it should appear on every response, success or failure, and be propagated through the entire request path.

Further reading