Happy-path APIs are easy. What separates a professional API from a hobby one is how it behaves when things go wrong. In simple language — good error handling means the caller always knows what broke, whose fault it was, and what to do next.
Two things make this work: the right status code (the machine-readable signal) and a consistent error body (the human/developer-readable detail).
Get the status code right
The status code is the first thing every client, proxy, and monitoring tool looks at. The number range alone tells a story.
401 Unauthorized (not logged in)
403 Forbidden (logged in, not allowed)
404 Not Found
409 Conflict
422 Unprocessable Entity
429 Too Many Requests
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout
The golden rule: 4xx means the client should fix its request; 5xx means the client should retry or wait for us to fix things. Mixing these up is a classic bug — returning 200 OK with {"error": "..."} in the body is the worst offender, because it lies to every tool watching the response.
401 vs 403 — the perennial mixup
Easy way to remember: 401 = “who are you?” (we don’t know you, log in), 403 = “I know you, and no” (you’re authenticated but not authorized). The only difference is whether identity is the problem or permission is.
Validation errors: 400 vs 422
Both mean “your input is bad”, and honestly people argue about this forever. The common convention:
- 400 Bad Request — the request is malformed. Broken JSON, missing a required field, wrong content type. The server can’t even parse it properly.
- 422 Unprocessable Entity — the JSON parsed fine, but the values fail our business rules. Email isn’t a valid email, age is negative.
Think of it like — 400 is “I can’t read this”, 422 is “I read it, and it’s wrong”.
A consistent error shape
Every error our API returns should look the same. Nothing is more painful than an API where every endpoint fails differently. A solid minimum:
{
"code": "VALIDATION_ERROR",
"message": "One or more fields are invalid.",
"details": [
{ "field": "email", "issue": "must be a valid email address" },
{ "field": "age", "issue": "must be >= 0" }
],
"requestId": "req_a1b2c3d4"
}
Three parts do the heavy lifting:
code— a stable, machine-readable string. Clients branch on this, not on the human message. We can rewordmessageanytime;codeis the contract.message— a short, human-friendly sentence for a developer reading logs.details— the field-by-field breakdown, so a frontend can highlight exactly what’s wrong.
Standardize with problem+json (RFC 9457)
Instead of inventing our own shape, there’s a standard: Problem Details for HTTP APIs, originally RFC 7807, now updated by RFC 9457. It defines a JSON body served with Content-Type: application/problem+json.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "The 'email' field must be a valid email address.",
"instance": "/users",
"requestId": "req_a1b2c3d4"
}
The nice bit: type is a URL that can point to docs about that error, and tooling already understands the format. We can add our own fields (like requestId) freely.
Never leak internals
This one’s both a security and a professionalism issue. Never send stack traces, SQL, or file paths to the client. A raw 500 dump tells an attacker our framework, our DB, our directory layout.
// ✗ BAD — leaks everything
{ "error": "ER_DUP_ENTRY at /app/src/db/users.js:42: duplicate key 'email'" }
// ✓ GOOD — safe outside, full detail in our logs
{ "code": "EMAIL_TAKEN", "message": "That email is already registered.",
"requestId": "req_a1b2c3d4" }
Keep the juicy details in our server logs, keyed by the same requestId.
Correlation / request IDs
That requestId is the glue between the client and our logs. We generate one per request (or accept an incoming X-Request-Id header), attach it to every log line, and return it in every error body — and ideally every response header.
When a user reports “it failed at 3pm”, we ask for the request ID and jump straight to those exact log lines. In a microservices world we pass this correlation ID downstream so we can trace one request across five services.
Interview soundbite
Return honest status codes — 4xx for the caller’s mistake, 5xx for ours, never 200 with an error inside. Ship a consistent error body with a stable machine code, a human message, and field-level details; the application/problem+json standard (RFC 9457) is a ready-made shape. Never leak stack traces or SQL, and stamp every request with a correlation ID you also write into your logs so a reported failure maps to exact log lines.