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
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.
🚨 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.
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:
status — the HTTP code, echoed for convenience.code — 🚨 a stable, machine-readable error code (INSUFFICIENT_FUNDS). Clients branch on
this, never on the human message. It’s part of your contract and must not change.title / detail — human-readable. detail may be shown to users or logged; title is the
category.requestId — 🚨 the most operationally valuable field. It ties the error to your logs and
traces, so when a customer says “I got an error,” you can find the exact request. Include it on
every response, success or failure. → Distributed Tracing🚨 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.
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.
🚨 Error messages are a security surface. A verbose error is a gift to an attacker.
Never expose in an error response:
syntax error near 'DROP' confirms a SQL injection vector.404 is better than 403, so you don’t
even confirm the resource exists to someone who can’t access it.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
🚨 Tell the client whether and when to retry. This is what makes robust clients possible.
Retry-After header. Without it, clients retry immediately and make
overload worse. → Rate LimitingA 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.
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:
207 Multi-Status with per-item results, so the client knows exactly which succeeded and which
failed and why.Different styles worth knowing:
200 OK with an errors array, because a query can partially succeed (some
fields resolve, some fail). 🚨 This breaks the “check the status code” habit and confuses monitoring
— you must inspect the body. → GraphQLNOT_FOUND, PERMISSION_DENIED, RESOURCE_EXHAUSTED,
UNAVAILABLE, …) that map roughly to HTTP but are richer, plus a details mechanism for structured
error data. → gRPC| 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 |
code, a human message, a type
categorizing the error, a param pointing at the offending field, and always the ability to look up
the request. Their consistency across thousands of endpoints is what makes the API trustworthy to
build on.type, title, status,
detail, instance), and adopting it means clients and tooling already understand your errors.
Knowing it exists is a good signal.200 with an error in the body. Breaks the entire ecosystem.500 for client (validation) errors. Causes infinite retries and pollutes metrics.Retry-After on 429/503.requestId, making every error un-debuggable.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.
200 with an error in the body a serious mistake?