Error Response Design
Errors are part of your API contract. Compare ad-hoc error shapes with a consistent problem+json format, add field-level details clients can act on, and keep stack traces and SQL out of responses.
🎯 What you'll learn
- The real cost of ad-hoc error shapes
- A minimal consistent error shape
- Status code discipline and field-level errors
- problem+json and never leaking internals
📚 Before you start
1Why an Ad-Hoc Error Shape Is a Real Cost
An API that returns a different error shape from every endpoint — sometimes a string, sometimes {error: "..."}, sometimes {message: "...", details: [...]} — forces every client to write bespoke error-handling per endpoint instead of one shared error-handling path. A consistent error shape is what lets a client write one generic "show the user this error" function that works for the entire API surface.
2A Minimal Consistent Error Shape
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request could not be processed due to invalid fields.",
"details": [
{ "field": "email", "issue": "must be a valid email address" },
{ "field": "age", "issue": "must be a positive integer" }
]
}
}Three distinct pieces, each serving a different consumer:
code— a stable, machine-readable identifier a client canswitchon programmatically (never changes across API versions without a deprecation cycle, unlikemessage)message— a human-readable summary, safe to log or show a developer, NOT guaranteed stable wording (may be improved/reworded without being a breaking change)details— structured, field-level breakdown for multi-field validation failures (section 4); omitted entirely for errors that aren't about specific fields
3HTTP Status Code Discipline
The status code and the body's error.code serve different audiences: the status code is for generic HTTP infrastructure (proxies, caches, monitoring, browsers) that doesn't parse your JSON at all; error.code is for your own client code that does:
| Status | Use for | NOT for |
|---|---|---|
400 Bad Request | Malformed request the server couldn't even parse/understand (bad JSON, wrong types) | A well-formed request that fails a business rule — see 422 |
401 Unauthorized | No valid identity at all | A valid identity that's simply not allowed to do this — see 403 |
403 Forbidden | Authenticated, but not authorized for this specific action/resource | Missing/invalid credentials — see 401 |
404 Not Found | The resource genuinely doesn't exist | An existing resource the caller isn't allowed to see (some APIs deliberately return 404 here too, to avoid leaking existence — a legitimate design choice, not a mistake) |
409 Conflict | The request conflicts with the resource's current state (e.g. optimistic-concurrency version mismatch, duplicate unique key) | Plain validation failure |
422 Unprocessable Entity | Well-formed, parseable request that fails semantic/business validation (e.g. age: -5) | Malformed JSON — that's 400 |
429 Too Many Requests | Rate limit exceeded (see backend/api-design/07-rate_limiting) | — |
500 Internal Server Error | An unexpected server-side failure — never anything the client did wrong | Any case where the client could have avoided the error by sending a different request |
Collapsing 400 and 422 into "just always send 400" is the single most common status-code sloppiness — it forces every client to parse the error body to learn what a status-code check alone should have told them.
4Field-Level Validation Errors
A single request can fail validation on multiple fields at once — reporting only the first one forces the client into a slow fix-one-resubmit-see-the-next loop:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "3 fields failed validation.",
"details": [
{ "field": "email", "issue": "required" },
{ "field": "password", "issue": "must be at least 8 characters" },
{ "field": "birthdate", "issue": "must be a date in the past" }
]
}
}Collecting every failure before responding (rather than short-circuiting on the first) is a deliberate design choice tied to backend/api-design/08-request_validation's validation-library patterns — most schema-validation libraries support an "accumulate all errors" mode versus a "fail fast" mode, and the accumulate mode is almost always the right default for API error responses.
5problem+json (RFC 7807) — a Real Interoperable Standard
Rather than inventing a bespoke shape from scratch, RFC 7807 defines a standard application/problem+json error format some APIs adopt directly (and generic HTTP tooling increasingly recognizes):
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The request could not be processed due to invalid fields.",
"instance": "/orders",
"errors": [
{ "field": "email", "issue": "must be a valid email address" }
]
}type is a URI identifying the category of problem (can literally be a link to documentation about it), title is a short human summary, status mirrors the HTTP status for convenience, instance identifies which specific request instance failed. Adopting problem+json isn't required — a custom shape like section 2's is equally valid — but it's worth knowing it exists as the closest thing to an industry-standard answer, useful when interoperating with tooling that already expects it.
6Never Leak Internals in Error Messages
// VULNERABLE: leaks stack trace, DB engine, internal file paths to any client
catch (err) {
res.status(500).json({ error: { code: "INTERNAL", message: err.stack } });
}
// SAFE: log the full detail server-side, return a generic message to the client
catch (err) {
logger.error("unhandled error in POST /orders", { error: err }); // full detail, server-only
res.status(500).json({ error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred." } });
}A 500 response body is visible to anyone who can send the request, including an attacker deliberately triggering errors to map your stack, database engine, or internal paths — the full detail belongs in server-side logs (correlated by a request id the client-facing error can safely include), never in the response body itself.
⚠️ Common Pitfalls
- A different error shape per endpoint. Defeats the entire purpose — clients can't write one shared error-handling path (section 1).
- Collapsing
400and422. Forces clients to parse the body to distinguish "you sent garbage" from "you sent well-formed but invalid data" (section 3). - Stopping at the first validation failure. Forces a slow fix-one-resubmit loop instead of surfacing every problem in one response (section 4).
- Leaking stack traces or internal identifiers in
500bodies. A live information-disclosure risk, not just an aesthetic one (section 6). - Treating
messageas a stable API contract. Clients thatif (error.message === "...")instead of checkingerror.codebreak the moment wording is improved —codeis the only field meant to be programmatically stable.
📋 Quick Reference
{ error: { code, message, details? } } -- stable shape across every endpoint
code -- machine-readable, stable across wording changes, safe to switch() on
message -- human-readable, NOT a stable contract
details -- field-level breakdown, only for multi-field validation failures
400 malformed request 422 well-formed but semantically invalid
401 no valid identity 403 identity present, not authorized for this
404 doesn't exist 409 conflicts with current state
429 rate limited 500 unexpected server-side failure (never client's fault)
Validation: accumulate ALL field errors, don't fail-fast on the first
500 bodies: generic message only -- full detail goes to server-side logs
problem+json (RFC 7807): a standard shape worth knowing, not mandatory🧠 Key takeaways
- Use one error shape across the API, ideally
application/problem+json. - Correct status code + stable machine-readable type/code + human title/detail.
- Field-level errors as an array of
{field, code, message}; include a traceId. - Never return stack traces, SQL or internal hostnames; log them server-side.
🎤 Interview Q&A
Q1What should a good API error response contain?core▾
A correct HTTP status, a machine-readable error code/type, a human-readable message, details (field errors), and a correlation/trace ID for support. Never stack traces or internal details.
- Standard format? RFC 9457 Problem Details:
type,title,status,detail,instance.
Q2How do you handle unexpected server errors?core▾
Catch them in a global error handler, log the full details with the trace ID, return a generic 500 with the trace ID, and alert based on error rates.
- Why not return the exception message? It can leak internals (SQL, paths, secrets).
Q3How do you design error codes?senior▾
Stable, documented, specific codes (card_declined, insufficient_funds, validation_failed) that clients can branch on, independent of message wording.
- Why not rely on messages? Messages change and get localized.
Q4How should errors from downstream services be surfaced?core▾
Translate them into your API's error model: a downstream timeout becomes 503/504 (maybe with Retry-After), a downstream validation error becomes a meaningful 4xx if it's the client's fault. Don't proxy raw downstream errors.
- Why translate? To avoid coupling clients to internal services.
Q5Should 404 and 403 be distinguishable?core▾
For non-sensitive resources yes; for sensitive ones, return 404 for both to avoid confirming existence to unauthorized users.
- Example? Private repositories on GitHub return 404 to non-members.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.