Engineering Notes
Home/Backend/API Design/Error Response Design
Backend · API Design

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.

⚙️ Backend● Beginner⏱ 6 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 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

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

json
{
  "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 can switch on programmatically (never changes across API versions without a deprecation cycle, unlike message)
  • 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:

StatusUse forNOT for
400 Bad RequestMalformed request the server couldn't even parse/understand (bad JSON, wrong types)A well-formed request that fails a business rule — see 422
401 UnauthorizedNo valid identity at allA valid identity that's simply not allowed to do this — see 403
403 ForbiddenAuthenticated, but not authorized for this specific action/resourceMissing/invalid credentials — see 401
404 Not FoundThe resource genuinely doesn't existAn 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 ConflictThe request conflicts with the resource's current state (e.g. optimistic-concurrency version mismatch, duplicate unique key)Plain validation failure
422 Unprocessable EntityWell-formed, parseable request that fails semantic/business validation (e.g. age: -5)Malformed JSON — that's 400
429 Too Many RequestsRate limit exceeded (see backend/api-design/07-rate_limiting)—
500 Internal Server ErrorAn unexpected server-side failure — never anything the client did wrongAny 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:

json
{
  "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):

json
{
  "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.

A problem+json error body with type, title, status, detail, instance and field errors, with status code as category and type as a stable machine-readable reason
A problem+json error body with type, title, status, detail, instance and field errors, with status code as category and type as a stable machine-readable reason

6Never Leak Internals in Error Messages

ts
// 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

  1. A different error shape per endpoint. Defeats the entire purpose — clients can't write one shared error-handling path (section 1).
  2. Collapsing 400 and 422. Forces clients to parse the body to distinguish "you sent garbage" from "you sent well-formed but invalid data" (section 3).
  3. Stopping at the first validation failure. Forces a slow fix-one-resubmit loop instead of surfacing every problem in one response (section 4).
  4. Leaking stack traces or internal identifiers in 500 bodies. A live information-disclosure risk, not just an aesthetic one (section 6).
  5. Treating message as a stable API contract. Clients that if (error.message === "...") instead of checking error.code break the moment wording is improved — code is the only field meant to be programmatically stable.

📋 Quick Reference

text
{ 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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. Why include a stable error code/type?
Messages change.
2. Returning 200 with {success:false}:
Use real status codes.
3. What should a 500 body contain?
Details in logs.
4. problem+json's 'type' field is…
Documentable.