Introduction
Every API eventually fails. A database times out, a client sends malformed JSON, a rate limit is exceeded, or a downstream service goes dark. What separates a well-engineered REST API from a fragile one is not whether these failures happen - they always do - but how clearly the API communicates what went wrong and what the caller should do next. Error handling is often treated as an afterthought, bolted on after the "happy path" is built, and this is precisely why so many production APIs end up with inconsistent, ambiguous, or outright misleading error responses.
Good error design is a contract. It tells the consumer of your API whether to retry, whether to fix their request, whether to contact support, or whether to simply give up. When that contract is inconsistent - say, one endpoint returns 400 for a missing field while another returns 422 for the same class of problem - every client integration becomes more fragile, and every incident takes longer to diagnose. This article walks through the mechanics of REST API error codes: the HTTP status code taxonomy, how to structure error response bodies, common pitfalls, and practices that scale across large API surfaces.
Context: Why Error Codes Are Harder Than They Look
At first glance, error handling in REST looks simple: pick a status code from the HTTP specification, attach a message, done. In practice, the difficulty comes from the fact that HTTP status codes were designed as a general-purpose protocol-level signaling mechanism, not as an application-level error taxonomy. RFC 9110, which defines the semantics of HTTP, groups status codes into five classes:
- informational (1xx)
- successful (2xx)
- redirection (3xx)
- client error (4xx)
- server error (5xx)
- but it intentionally leaves enormous latitude for how applications use codes within those classes.
This means two APIs can both be "RFC compliant" while disagreeing sharply on what a 409 Conflict or a 422 Unprocessable Content actually means for a specific business scenario.
The second source of difficulty is that clients act differently depending on which class of code they see, often programmatically. Retry logic, exponential backoff, circuit breakers, and alerting thresholds are frequently keyed directly off status code ranges. If your API returns 500 for something that is actually a client mistake - say, an invalid enum value - a well-behaved client might retry the request several times, wasting resources and delaying the discovery of the real bug. Conversely, if you return 400 for a transient server-side issue, clients may give up immediately when a retry would have succeeded. The status code isn't just documentation; it actively shapes client behavior, which makes precision important rather than cosmetic.
Finally, there's the tension between generic HTTP semantics and domain-specific meaning. A 404 Not Found might mean "this resource genuinely does not exist," or it might be used defensively to hide the existence of a resource the caller isn't authorized to see (a technique used by GitHub's API, among others, to avoid leaking information through 403 responses). Deciding when to use HTTP's generic vocabulary versus when to layer your own application-level codes on top is one of the central design decisions covered in the next section.
Deep Technical Explanation: The Anatomy of a Good Error Response
HTTP Status Codes as the First Layer
The status code is the coarse-grained signal - the layer that infrastructure (load balancers, API gateways, monitoring systems, HTTP client libraries) reads without knowing anything about your business domain. The most commonly used codes in REST APIs fall into a fairly small, well-understood set. In the 4xx range: 400 Bad Request for malformed syntax or invalid parameters, 401 Unauthorized for missing or invalid authentication, 403 Forbidden for authenticated-but-not-permitted requests, 404 Not Found for missing resources, 409 Conflict for state conflicts such as duplicate creation or optimistic concurrency failures, 422 Unprocessable Content for semantically invalid input that is syntactically well-formed, and 429 Too Many Requests for rate limiting. In the 5xx range: 500 Internal Server Error as a catch-all, 502 Bad Gateway and 504 Gateway Timeout for upstream failures, and 503 Service Unavailable for deliberate throttling or maintenance windows.
The distinction between 400 and 422 trips up many teams. 400 is meant for requests that are malformed at the protocol or syntax level - invalid JSON, a missing required header. 422, defined originally in RFC 4918 (WebDAV) and later formalized for general HTTP use in RFC 9110's errata and widely adopted by JSON:API and other REST conventions, is meant for requests that are syntactically valid but semantically wrong - for example, a well-formed JSON body where email is present but not a valid email address. Making this distinction consistently lets clients build different handling logic: a 400 often indicates a client bug (worth alerting on), while a 422 often indicates bad user input (worth surfacing to an end user, not necessarily an engineer).
Structured Error Bodies as the Second Layer
The status code alone is rarely enough. A 422 doesn't tell the caller which field failed validation or why. This is where a structured error body becomes essential. The IETF's RFC 9457, "Problem Details for HTTP APIs" (which obsoletes the earlier RFC 7807), defines a standard JSON media type, application/problem+json, with a small set of well-defined fields: type (a URI identifying the error type), title (a short, human-readable summary), status (the HTTP status code, duplicated for convenience), detail (a human-readable explanation specific to this occurrence), and instance (a URI identifying this specific occurrence). Extensions can be added freely for domain-specific fields such as validation error lists.
Adopting a standard like RFC 9457 has a real practical benefit: HTTP client libraries and API gateways can build generic tooling around it rather than every team inventing its own error envelope. Without a standard, it's common to see three different error shapes across different microservices in the same organization - one using { error: string }, another { message: string, code: number }, another { errors: [{ field, reason }] }. This fragmentation makes it much harder to build shared client SDKs, shared error-logging middleware, or shared retry logic.
Implementation: Practical Examples
Designing a Consistent Error Envelope in TypeScript
A common pattern for medium-to-large APIs is to define a small, closed set of application-level error codes that map predictably onto HTTP status codes, then enforce that mapping through a shared error class hierarchy. This avoids the situation where individual engineers pick status codes ad hoc.
// errors.ts
export type ApiErrorCode =
| "VALIDATION_FAILED"
| "RESOURCE_NOT_FOUND"
| "DUPLICATE_RESOURCE"
| "RATE_LIMITED"
| "UPSTREAM_TIMEOUT"
| "INTERNAL_ERROR";
interface ApiErrorOptions {
code: ApiErrorCode;
httpStatus: number;
detail: string;
fields?: Record<string, string>;
retryable: boolean;
}
export class ApiError extends Error {
readonly code: ApiErrorCode;
readonly httpStatus: number;
readonly detail: string;
readonly fields?: Record<string, string>;
readonly retryable: boolean;
constructor(opts: ApiErrorOptions) {
super(opts.detail);
this.code = opts.code;
this.httpStatus = opts.httpStatus;
this.detail = opts.detail;
this.fields = opts.fields;
this.retryable = opts.retryable;
}
toProblemJSON(instancePath: string) {
return {
type: `https://api.example.com/errors/${this.code.toLowerCase()}`,
title: this.code.replace(/_/g, " ").toLowerCase(),
status: this.httpStatus,
detail: this.detail,
instance: instancePath,
retryable: this.retryable,
...(this.fields ? { errors: this.fields } : {}),
};
}
}
// Usage in a request handler
export function validateCreateUser(body: unknown): void {
const fields: Record<string, string> = {};
if (typeof (body as any)?.email !== "string" || !(body as any).email.includes("@")) {
fields.email = "must be a valid email address";
}
if (Object.keys(fields).length > 0) {
throw new ApiError({
code: "VALIDATION_FAILED",
httpStatus: 422,
detail: "One or more fields failed validation.",
fields,
retryable: false,
});
}
}
The value of this pattern is that retryable becomes an explicit, first-class property rather than something clients have to infer from the status code alone. A 503 might be retryable, but a 503 returned during a planned maintenance window with no expected recovery time might not be - the field makes that explicit rather than leaving it to convention.
Centralized Error Handling in an Express/Node Middleware
Scattering try/catch blocks with inline status codes across every route handler is one of the fastest ways to end up with inconsistent errors. Centralizing translation from internal exceptions to HTTP responses in one place keeps the mapping auditable.
import { Request, Response, NextFunction } from "express";
import { ApiError } from "./errors";
export function errorHandler(
err: unknown,
req: Request,
res: Response,
_next: NextFunction
) {
if (err instanceof ApiError) {
res
.status(err.httpStatus)
.type("application/problem+json")
.json(err.toProblemJSON(req.originalUrl));
return;
}
// Unknown errors: never leak internals, but log them fully server-side.
console.error("Unhandled error", err);
res.status(500).type("application/problem+json").json({
type: "https://api.example.com/errors/internal_error",
title: "internal server error",
status: 500,
detail: "An unexpected error occurred. Our team has been notified.",
instance: req.originalUrl,
retryable: true,
});
}
Client-Side Retry Logic in Python
On the consuming side, clients benefit from treating retryability as data rather than hardcoding status-code lists everywhere. Here is a simplified retry wrapper using the requests library that respects both standard status codes and the retryable field when a JSON body is present.
import time
import requests
RETRYABLE_STATUSES = {429, 502, 503, 504}
MAX_RETRIES = 4
BASE_DELAY_SECONDS = 0.5
def call_with_retries(method, url, **kwargs):
for attempt in range(MAX_RETRIES + 1):
response = requests.request(method, url, **kwargs)
if response.status_code < 400:
return response
should_retry = response.status_code in RETRYABLE_STATUSES
try:
body = response.json()
if isinstance(body, dict) and "retryable" in body:
should_retry = bool(body["retryable"])
except ValueError:
pass
if not should_retry or attempt == MAX_RETRIES:
response.raise_for_status()
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else BASE_DELAY_SECONDS * (2 ** attempt)
time.sleep(delay)
return response
This respects the Retry-After header, which is defined in RFC 9110 and commonly returned alongside 429 and 503 responses, giving the server explicit control over backoff timing rather than leaving clients to guess.
Trade-offs and Common Pitfalls
Teams frequently over-engineer or under-engineer error taxonomies, and both failure modes are costly in different ways. Over-engineering shows up as dozens of highly specific application-level error codes, each mapped to a slightly different HTTP status, with no clear rule for when to introduce a new one. This tends to produce documentation that is exhausting to maintain and client code full of special cases that break the first time a new, unanticipated error code appears. A more sustainable approach is to keep the set of application-level codes small and stable, and let the detail field (free text) carry the specific, human-readable nuance rather than encoding every possible failure mode into its own machine-readable code.
Under-engineering is more common and arguably more damaging: returning 500 for everything, or worse, returning 200 OK with an error described only in the response body. This second pattern - sometimes justified by a desire to "simplify client handling" - actually makes things significantly worse, because it breaks every piece of standard HTTP tooling. Load balancers, CDNs, API gateways, browser dev tools, and HTTP client libraries all use status codes for caching decisions, logging, and alerting. An API that always returns 200 forces every single consumer to parse the body just to know if the call succeeded, defeating decades of HTTP tooling built around status codes as the primary success/failure signal.
Another subtle pitfall is information leakage through error messages. Detailed stack traces, SQL error strings, or internal file paths in a 500 response body are a genuine security liability; they've been used in real-world attacks to fingerprint backend technology stacks and craft targeted exploits. The fix is straightforward but requires discipline: log full detail server-side (with correlation IDs), and return only sanitized, generic detail to the client, alongside a unique reference ID the caller can quote when contacting support. Finally, teams often forget to version their error contracts alongside their API version - changing the meaning of an existing error code, or repurposing a status code for a new scenario, is a breaking change and should be treated with the same rigor as changing a response schema.
Best Practices for Designing Error Codes
A handful of practices consistently separate APIs with error handling that developers trust from those that generate a steady stream of support tickets. First, standardize on a single error body schema across your entire API surface, ideally an existing standard such as RFC 9457's Problem Details, rather than inventing a bespoke format. Second, make the retryability of an error explicit and machine-readable rather than something clients must infer from a status code that might be ambiguous. Third, never let a 4xx or 5xx distinction blur: 4xx should always mean "the client should change something before retrying," and 5xx should always mean "the server failed independent of what the client sent" - mixing these up misdirects debugging effort during incidents.
It's also worth investing in error documentation as a first-class API artifact, not an afterthought buried at the bottom of a reference page. Each documented endpoint should list the specific error codes it can return, with example response bodies, in the same way it lists success responses. Tools like the OpenAPI Specification support this directly through the responses object, where non-2xx status codes can be documented with the same rigor as success cases; teams that skip this section consistently see more integration friction from third-party developers. Finally, treat changes to error semantics as part of your API's versioning discipline - if you must change what a code means, introduce it behind a new API version rather than silently altering behavior that existing clients depend on.
Key Takeaways
- Reserve
4xxstrictly for client-caused problems and5xxstrictly for server-caused problems; never blur this line even under deadline pressure. - Adopt a structured, standardized error body (RFC 9457's Problem Details is a strong default) instead of inventing a new shape per team or service.
- Make retryability an explicit, machine-readable property of the response rather than something clients infer from status codes alone.
- Never return
200 OKfor a failed operation, even if it seems simpler for a specific client - it breaks standard HTTP tooling and monitoring. - Document every error code an endpoint can return with the same care given to success responses, ideally directly inside your OpenAPI spec.
Conclusion
Error codes are one of the most under-designed parts of REST API architecture, despite being one of the most consequential for real-world reliability. A thoughtful error taxonomy - a small, stable set of application error codes cleanly mapped to correct HTTP status classes, wrapped in a structured and standardized response body - pays for itself many times over in reduced support burden, faster incident diagnosis, and client integrations that behave predictably under failure. The goal isn't to enumerate every conceivable failure mode; it's to give every consumer of your API, human or machine, enough signal to make the right decision the moment something goes wrong.
References
- IETF RFC 9110, "HTTP Semantics" - https://www.rfc-editor.org/rfc/rfc9110
- IETF RFC 9457, "Problem Details for HTTP APIs" - https://www.rfc-editor.org/rfc/rfc9457
- IETF RFC 7807, "Problem Details for HTTP APIs" (obsoleted by RFC 9457) - https://www.rfc-editor.org/rfc/rfc7807
- MDN Web Docs, "HTTP response status codes" - https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
- OpenAPI Initiative, "OpenAPI Specification" - https://spec.openapis.org/oas/latest.html
- JSON:API Specification, "Errors" - https://jsonapi.org/format/#errors
- GitHub REST API Documentation, "Troubleshooting" (example of using 404 for authorization concealment) - https://docs.github.com/en/rest