API Error Handling: Designing Useful and Consistent Errors
A production guide to API error handling using RFC 9457 Problem Details, semantic HTTP status codes, safe retry strategies, and observability boundaries.

API Error Handling: Designing Useful and Consistent Errors
In distributed systems, the quality of an API is determined as much by how it communicates failure as by how it serves successful requests. During local prototyping, APIs frequently operate under benign conditions: network sockets do not drop packets, database connections remain persistent, input payloads conform to documentation, and external payment processors never time out.
Production environments routinely invalidate those assumptions. Concurrent mutations trigger lock conflicts, downstream microservices become temporarily unreachable, schema migrations introduce temporary protocol divergence, and mobile clients send expired authentication tokens across degraded cellular links.
When failure occurs, an API must satisfy four operational objectives simultaneously:
- Machine Readability: Client SDKs, frontend interceptors, and automated background workers must be able to inspect structured error codes and branch predictably without parsing freeform human text.
- Human Diagnostic Utility: API consumers, third-party developers, and frontend engineers must receive clear explanations of what went wrong, which input parameters failed validation, and how to correct the request.
- Information Security: Internal implementation details—such as database table names, SQL dialects, microservice hostnames, and stack traces—must never leak to untrusted callers (mitigating CWE-209).
- Operational Observability: Internal logging and distributed tracing systems must correlate every client-facing error with server-side diagnostic telemetry through immutable request identifiers and trace contexts.
Achieving these four requirements requires moving beyond ad-hoc error payloads toward standardized protocols, rigorous status code semantics, clean architectural boundaries, and disciplined retry behaviors.
The Cost of Ad-Hoc Error Envelopes
Many development teams begin with informal, bespoke error formats. A common pattern is returning an ad-hoc JSON object with arbitrary property names:
// Anti-pattern: Inconsistent, unstructured error envelopes
{
"error": "Payment failed",
"reason": "Insufficient balance",
"code": 1042
}
Or worse, returning HTTP 200 OK with a boolean success flag:
// Anti-pattern: HTTP 200 OK masking failure
{
"success": false,
"message": "User unauthorized"
}
These ad-hoc approaches introduce severe architectural friction in production environments:
- Bypassing Transport-Level Infrastructure: Edge gateways, reverse proxies (e.g., Envoy, Nginx), content delivery networks (CDNs), and Application Performance Monitoring (APM) agents inspect the HTTP status line. When an API returns
200 OKfor an authorization failure, caching proxies may store the failure response, while APM dashboards report 100% service health despite widespread client outages. - Fragile Client Parsing: If an error payload lacks a stable, machine-readable discriminator, frontend developers often resort to string parsing (such as
if (err.message.includes("not found"))). Any future copy edit, typo fix, or localization update silently breaks client application logic. - Polymorphic Envelopes Across Endpoints: Without a shared standard, different services within an organization return conflicting schemas: Service A returns
{ "errors": ["..."] }, Service B returns{ "error": { "message": "..." } }, and Service C returns{ "detail": "..." }. Client SDKs require custom deserialization logic for every single endpoint.
To build predictable systems, an API platform must adopt a formal, standardized error representation across all endpoints.
The Industry Standard: RFC 9457 Problem Details for HTTP APIs
In July 2023, the Internet Engineering Task Force (IETF) published RFC 9457: Problem Details for HTTP APIs, which obsoleted and superseded RFC 7807. RFC 9457 establishes a standard media type—application/problem+json (and application/problem+xml)—designed specifically to communicate machine-readable problem details in HTTP responses.
RFC 9457 defines five standard top-level members:
| Member Name | Data Type | Requirement & Semantics |
| :--- | :--- | :--- |
| type | string (URI) | Identifies the problem type. When dereferenced by a browser or developer, it should ideally provide human-readable documentation. Defaults to "about:blank" if omitted. |
| title | string | A short, human-readable summary of the problem type. RFC 9457 specifies that title SHOULD NOT change between occurrences of the same problem type (except for localization). |
| status | number | The HTTP status code generated by the origin server for this occurrence. Must match the actual status code in the HTTP response line. |
| detail | string | A human-readable explanation specific to this occurrence of the problem, explaining the concrete context of the failure. |
| instance | string (URI) | A URI reference that identifies the specific occurrence of the problem. Often an operational URI (e.g., /orders/ord_9821/items) or a stable UUID URN (urn:uuid:...). |
Extending RFC 9457 for Complex Domain Failures
RFC 9457 Section 4 explicitly permits origin servers to define extension members to convey additional problem-specific data. The most critical use case is field-level input validation failures.
When a client submits a payload containing multiple invalid fields, returning only a top-level error string forces the client into a tedious "submit, fail, fix one field, resubmit" cycle. An RFC 9457 document solves this by including an invalid-params or errors array:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: en
{
"type": "https://api.example.com/errors/validation-error",
"title": "Invalid Request Parameters",
"status": 422,
"detail": "The request body contains 2 semantic validation errors.",
"instance": "urn:uuid:6c9b4e18-3a21-4f12-8d7b-e52a91f3a210",
"invalid-params": [
{
"name": "email",
"reason": "Must be a valid RFC 5322 email address",
"value": "user-at-domain.com"
},
{
"name": "billing.postalCode",
"reason": "Postal code does not match the specified country format (US)",
"value": "ABCDE"
}
]
}
For state or concurrency conflicts, an RFC 9457 payload can supply the current resource state or conflicting entity reference:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/concurrency-conflict",
"title": "Resource State Conflict",
"status": 409,
"detail": "The order cannot be modified because it has already transitioned to 'shipped'.",
"instance": "/orders/ord_874192",
"currentStatus": "shipped",
"requestedStatus": "cancelled",
"version": 4
}
Adopting RFC 9457 standardizes error ingestion across iOS, Android, web applications, and backend integration workers, allowing shared client middleware to automatically deserialize, log, and route errors based on type and status.
HTTP Status Code Precision: Semantics That Guide Client Action
HTTP status codes are not stylistic decorations; they define the exact behavioral contract between client, reverse proxy, and server. Automated client libraries, CDNs, and API gateways inspect these codes to decide whether to cache the response, retry the connection, or drop the circuit.
Imprecise status code usage creates subtle bugs. Below is an engineering analysis of status code distinctions that frequently cause production friction.
Client Request
│
┌──────────────┴──────────────┐
▼ ▼
Syntax / Framing Error Semantics & Business Rules
- Malformed JSON bytes - Valid JSON, but negative amount
- Invalid framing / headers - Missing required schema property
│ │
▼ ▼
HTTP 400 Bad Request HTTP 422 Unprocessable Content
1. 400 Bad Request vs. 422 Unprocessable Content
- 400 Bad Request: Indicates that the server cannot process the request due to a client error in syntax, framing, or routing. This applies when the request body contains malformed JSON (e.g., unclosed brackets, invalid byte escapes), unexpected multipart delimiters, or corrupt headers. The parser fails before business schema validation can begin.
- 422 Unprocessable Content: Originally introduced in RFC 4918 (WebDAV) and incorporated into core HTTP Semantics in RFC 9110 Section 15.5.21, 422 indicates that the server understands the content type and the syntax of the request entity is correct, but was unable to process the contained instructions. In a JSON REST API, 422 is the exact code for schema validation failures: the JSON is structurally sound, but an integer is negative, a string exceeds length limits, or a required field is missing.
2. 401 Unauthorized vs. 403 Forbidden
- 401 Unauthorized: Defined in RFC 9110 Section 15.5.2. Despite the name, 401 signifies unauthenticated. The request lacks valid authentication credentials. RFC 9110 mandates that the response MUST include a
WWW-Authenticateheader specifying the challenge scheme (e.g.,WWW-Authenticate: Bearer realm="api", error="invalid_token"). A client can acquire a valid token and retry the request. - 403 Forbidden: The server understood the request and authenticated the caller's identity, but the caller does not possess the authorization permissions necessary to access the target resource. Retrying the request with the same credentials will never succeed.
3. 404 Not Found vs. 410 Gone
- 404 Not Found: The origin server did not find a current representation for the target resource, or is not willing to disclose that one exists (to prevent enumeration attacks). 404 does not indicate whether the resource was deleted or never existed.
- 410 Gone: Defined in RFC 9110 Section 15.5.11. The target resource is no longer available at the origin server, and this condition is expected to be permanent. When search engine crawlers receive 410, they immediately purge the URL from indexes; caching proxies evict the resource without conditional revalidation. Use 410 when an API resource has been permanently retired or purged.
4. 409 Conflict
- Indicates that the request cannot be completed due to a conflict with the current state of the resource. Common scenarios include:
- Optimistic Concurrency Failures: The client's entity version or
If-MatchETag does not match the server's current version. - Unique Constraint Violations: Attempting to register an account with an email address or slug that already exists.
- Illegal State Transitions: Attempting to transition an order from "refunded" to "delivered".
- Optimistic Concurrency Failures: The client's entity version or
5. 429 Too Many Requests (RFC 6585)
- Indicates that the client has exhausted its allocated rate limit or concurrency quota.
- A well-designed 429 response MUST include the
Retry-Afterheader (RFC 9110 Section 10.2.3), providing the client with an explicit duration to wait before making another request:HTTP/1.1 429 Too Many Requests Retry-After: 30 RateLimit-Limit: 100 RateLimit-Remaining: 0 RateLimit-Reset: 30Retry-Aftercan be expressed in integer seconds (e.g.,30) or as an IMF-fixdate HTTP-date (e.g.,Wed, 21 Oct 2026 07:28:00 GMT).
6. 502 Bad Gateway vs. 503 Service Unavailable vs. 504 Gateway Timeout
- 502 Bad Gateway: An edge proxy, reverse proxy, or API gateway received an invalid response (e.g., connection reset by peer, malformed HTTP headers) from the upstream microservice.
- 503 Service Unavailable: The origin server is currently unable to handle the request due to temporary overload or scheduled maintenance. It implies that the condition is transient and should be paired with a
Retry-Afterheader. - 504 Gateway Timeout: The reverse proxy or API gateway did not receive a response from the upstream microservice within its configured timeout window.
Status Code Decision Matrix
| Status Code | RFC Reference | Retryable by Client? | Standard Companion Headers |
| :--- | :--- | :--- | :--- |
| 400 Bad Request | RFC 9110 §15.5.1 | No (requires payload correction) | None |
| 401 Unauthorized | RFC 9110 §15.5.2 | Yes (after authenticating) | WWW-Authenticate (Mandatory) |
| 403 Forbidden | RFC 9110 §15.5.4 | No (requires privilege change) | None |
| 404 Not Found | RFC 9110 §15.5.5 | No (unless created asynchronously) | None |
| 409 Conflict | RFC 9110 §15.5.10 | Yes (after fetching latest state) | ETag, Location (optional) |
| 410 Gone | RFC 9110 §15.5.11 | No (permanent deletion) | None |
| 422 Unprocessable | RFC 9110 §15.5.21 | No (requires valid schema fields) | None |
| 429 Too Many Requests | RFC 6585 §4 | Yes (after delay) | Retry-After, RateLimit-* |
| 500 Internal Error | RFC 9110 §15.6.1 | Uncertain (mutation may have committed) | None |
| 502 Bad Gateway | RFC 9110 §15.6.3 | Yes (with exponential backoff) | None |
| 503 Service Unavailable | RFC 9110 §15.6.4 | Yes (with backoff) | Retry-After (Recommended) |
| 504 Gateway Timeout | RFC 9110 §15.6.5 | Yes (with backoff) | None |
Architectural Separation: Domain Errors vs. Transport Representation
A critical error handling mistake is coupling domain business logic to the HTTP transport layer. When a core service method directly throws new HttpResponseException(404, "..."), several architectural liabilities emerge:
- Domain services cannot be reused in asynchronous message consumers (e.g., RabbitMQ, Apache Kafka, or AWS SQS), CLI batch tools, or gRPC endpoints without dragging in HTTP dependencies.
- Changes to HTTP error schemas require modifying core business logic.
- Testing domain rules requires mocking HTTP response contexts.
Following Hexagonal Architecture (Ports and Adapters) or Clean Architecture, domain services should signal business failures using typed domain errors or Result<T, E> types. A boundary transformation layer—such as a global error middleware or controller filter—then maps those domain errors into RFC 9457 HTTP responses.
┌─────────────────────────────────────────────────────────────┐
│ HTTP Transport Layer │
│ Incoming Request ───> Controller ───> Domain Invocation│
│ │ │
│ RFC 9457 Response <─── Error Middleware <───────┘ │
│ (Status, Headers) (Translates Domain Error) │
└───────────────────────────────────▲─────────────────────────┘
│ (Throws or returns Domain Error)
┌───────────────────────────────────┴─────────────────────────┐
│ Domain & Core Layer │
│ - OrderAlreadyShippedError │
│ - InsufficientBalanceError │
│ - EntityNotFoundError │
│ - SchemaValidationFailedError │
└─────────────────────────────────────────────────────────────┘
By decoupling these layers, the domain model focuses exclusively on business invariants, while the transport middleware assumes single responsibility for:
- Setting appropriate HTTP status codes and headers (
Content-Type: application/problem+json,Retry-After). - Generating RFC 9457 schema compliance.
- Attaching correlation identifiers (
instance,X-Request-Id). - Sanitizing sensitive internal details before sending the payload over the wire.
Production Security: Mitigating CWE-209 and Information Leakage
Software developers frequently overlook error responses as an attack vector. CWE-209: Generation of Error Message Containing Sensitive Information details the risks associated with exposing internal operational state to external actors.
When an unhandled exception occurs in a database driver or application framework, default error handlers often output:
- Raw SQL queries containing table, column, and schema names.
- SQL dialect and database versions (enabling targeted vulnerability exploits).
- Database hostnames, port numbers, and connection strings.
- Complete execution stack traces disclosing local filesystem paths, dependency versions, and internal service naming schemes.
The Dual-Channel Strategy
Production-grade error handling implements a strict Dual-Channel Strategy:
- The External Public Channel: The response sent across the network to the client. This channel must be strictly sanitized. For any internal 5xx error, the response must provide a generic, safe message. Under no circumstances should raw stack traces or internal exception messages be transmitted in production environments.
- The Internal Telemetry Channel: High-cardinality, structured JSON logs and distributed traces captured internally by systems like OpenTelemetry, Datadog, or Grafana Loki. This channel records the unredacted error message, full stack trace, causal error chain (
error.cause), and contextual execution metadata.
Correlating Telemetry with Distributed Traces
To allow customers or frontend engineers to report an issue to engineering support without exposing internal secrets, the server attaches an immutable Correlation Identifier to both channels.
External Client Channel Internal Observability Channel
(Sanitized RFC 9457 Payload) (High-Cardinality JSON Telemetry)
HTTP/1.1 500 Internal Server Error {
Content-Type: application/problem+json "level": "error",
"correlationId": "req_8f1b2c4d",
{ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"type": ".../internal-server-error", "error": "QueryFailedError: relation 'users'...",
"title": "Internal Server Error", "stack": "Error: relation 'users' does not exist...",
"status": 500, "userId": "usr_9912",
"detail": "An unexpected error occurred.", "sql": "SELECT * FROM users WHERE tenant_id = $1"
"instance": "urn:uuid:req_8f1b2c4d" }
}
The correlation identifier can be passed using standard headers:
X-Request-Id: Commonly assigned by edge reverse proxies or API gateways.- W3C Trace Context
traceparent(RFC 3721):00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01, which unifies logging across microservice hops.
When a customer reports an issue, support staff can query internal log aggregators using req_8f1b2c4d to view the complete, unredacted stack trace and database query instantly.
Safe Retries, Idempotency, and Exponential Backoff with Jitter
When an API client encounters a network failure or a 5xx response, its default reaction is often to retry. In a high-traffic production system, uncoordinated client retries can turn a transient hiccup into catastrophic service failure through Retry Storms (also known as the Thundering Herd problem).
The Idempotency Hazard
Retrying a request is only safe if the operation is idempotent—meaning executing it multiple times produces the exact same side effects on the server as executing it once.
- HTTP
GET,HEAD,PUT, andDELETEmethods are defined as idempotent by HTTP semantics. - HTTP
POSTandPATCHmethods are typically non-idempotent.
If a client sends a POST /payments request and the connection drops after 5,000 milliseconds, the client cannot know whether:
- The server crashed before processing the payment, OR
- The server processed the payment, charged the credit card, and the network dropped while transmitting the
201 Createdresponse.
If the client retries blindly, the customer may be billed twice.
The Idempotency-Key Pattern
To make non-idempotent operations safe to retry, robust APIs implement the IETF draft Idempotency-Key header pattern:
POST /v1/payments HTTP/1.1
Host: api.example.com
Idempotency-Key: 7b9e6f21-94b8-4c12-9c3a-23d9a1f2b450
Content-Type: application/json
{
"amount": 4900,
"currency": "USD"
}
Server Execution Flow:
- When a mutating request arrives, the server checks an atomic distributed cache (e.g., Redis with a configured Time-To-Live) for the provided
Idempotency-Key. - If the key exists and its status is
processing, the server returns409 Conflict(or blocks until the first request completes). - If the key exists and has completed, the server returns the cached response status, headers, and body directly to the client without re-executing the business mutation.
- If the key does not exist, the server creates an atomic lock, executes the business mutation, caches the final response, and returns it.
Exponential Backoff with Full Jitter
When retrying transient errors (e.g., 429 Too Many Requests, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout), clients must avoid sending retries at fixed, synchronized intervals. If 1,000 mobile clients fail simultaneously at , pure exponential backoff causes all 1,000 clients to retry at exactly , , , and , creating repeated traffic spikes that prevent the overloaded service from recovering.
The industry-standard solution is Exponential Backoff with Full Jitter, formalized by Michael Nielsen and AWS Architecture:
Where the parameters are defined as:
- : The base backoff duration (for example, 200 milliseconds).
- : The maximum backoff ceiling cap (for example, 30,000 milliseconds), preventing retry intervals from expanding toward infinity.
- : The zero-indexed retry attempt count ().
- : A uniform random real value chosen between and .
Full jitter distributes retry requests uniformly across the entire timeline between zero and the theoretical exponential maximum. This flattens the retry curve and eliminates periodic spikes, giving backend services the breathing room needed to clear queues and re-establish database connection pools.
Production Implementation: Type-Safe RFC 9457 Middleware in TypeScript
Below is a self-contained, production-grade TypeScript implementation demonstrating domain error classification, schema validation integration, and an RFC 9457 compliant error-handling middleware.
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
/**
* Standard RFC 9457 Problem Details Document interface.
*/
export interface ProblemDetails {
type: string;
title: string;
status: number;
detail: string;
instance: string;
[key: string]: unknown; // RFC 9457 extension members
}
export interface InvalidParam {
name: string;
reason: string;
value?: unknown;
}
/**
* Abstract Base Class for Domain and Application Errors.
* Keeps business errors decoupled from Express/HTTP routing logic.
*/
export abstract class AppError extends Error {
public abstract readonly statusCode: number;
public abstract readonly typeUri: string;
public abstract readonly title: string;
constructor(message: string, public override readonly cause?: unknown) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
/**
* Optional hook for subclasses to append RFC 9457 extension members.
*/
public getExtensions(): Record<string, unknown> {
return {};
}
}
/**
* 422 Unprocessable Content: Multi-field validation failures.
*/
export class ValidationError extends AppError {
public readonly statusCode = 422;
public readonly typeUri = 'https://api.example.com/errors/validation-error';
public readonly title = 'Unprocessable Content';
constructor(
detail: string,
public readonly invalidParams: InvalidParam[],
cause?: unknown
) {
super(detail, cause);
}
public override getExtensions(): Record<string, unknown> {
return { 'invalid-params': this.invalidParams };
}
}
/**
* 404 Not Found: Missing domain entity.
*/
export class ResourceNotFoundError extends AppError {
public readonly statusCode = 404;
public readonly typeUri = 'https://api.example.com/errors/resource-not-found';
public readonly title = 'Resource Not Found';
constructor(
public readonly resourceName: string,
public readonly resourceId: string
) {
super(`${resourceName} with identifier '${resourceId}' was not found.`);
}
public override getExtensions(): Record<string, unknown> {
return { resourceName: this.resourceName, resourceId: this.resourceId };
}
}
/**
* 409 Conflict: State machine or concurrency version conflict.
*/
export class ConcurrencyConflictError extends AppError {
public readonly statusCode = 409;
public readonly typeUri = 'https://api.example.com/errors/concurrency-conflict';
public readonly title = 'Conflict';
constructor(
detail: string,
public readonly entityId: string,
public readonly expectedVersion: number,
public readonly currentVersion: number
) {
super(detail);
}
public override getExtensions(): Record<string, unknown> {
return {
entityId: this.entityId,
expectedVersion: this.expectedVersion,
currentVersion: this.currentVersion,
};
}
}
/**
* 429 Too Many Requests: Rate limiting.
*/
export class RateLimitExceededError extends AppError {
public readonly statusCode = 429;
public readonly typeUri = 'https://api.example.com/errors/rate-limit-exceeded';
public readonly title = 'Too Many Requests';
constructor(
detail: string,
public readonly retryAfterSeconds: number
) {
super(detail);
}
public override getExtensions(): Record<string, unknown> {
return { retryAfterSeconds: this.retryAfterSeconds };
}
}
/**
* Production-Ready RFC 9457 Error Handling Middleware.
*/
export function rfc9457ErrorHandler(
err: unknown,
req: Request,
res: Response,
_next: NextFunction
): void {
const isProduction = process.env.NODE_ENV === 'production';
// 1. Resolve or generate consistent Correlation ID
const rawRequestId = req.headers['x-request-id'];
const requestId = typeof rawRequestId === 'string' && rawRequestId.trim()
? rawRequestId.trim()
: randomUUID();
const instanceUri = `urn:uuid:${requestId}`;
res.setHeader('X-Request-Id', requestId);
res.setHeader('Content-Type', 'application/problem+json');
// 2. Handle Known Domain Application Errors
if (err instanceof AppError) {
if (err instanceof RateLimitExceededError) {
res.setHeader('Retry-After', err.retryAfterSeconds.toString());
}
const payload: ProblemDetails = {
type: err.typeUri,
title: err.title,
status: err.statusCode,
detail: err.message,
instance: instanceUri,
...err.getExtensions(),
};
res.status(err.statusCode).json(payload);
return;
}
// 3. Handle Syntax Errors (e.g. malformed JSON body from bodyParser)
if (err instanceof SyntaxError && 'status' in err && (err as { status: unknown }).status === 400) {
const payload: ProblemDetails = {
type: 'https://api.example.com/errors/malformed-syntax',
title: 'Bad Request',
status: 400,
detail: 'The request body could not be parsed as valid JSON.',
instance: instanceUri,
};
res.status(400).json(payload);
return;
}
// 4. Handle Uncaught / Internal 500 Errors (Mitigating CWE-209)
const internalError = err instanceof Error ? err : new Error(String(err));
// Internal Channel: Full unredacted telemetry
console.error(JSON.stringify({
timestamp: new Date().toISOString(),
level: 'error',
correlationId: requestId,
path: req.originalUrl,
method: req.method,
errorMessage: internalError.message,
stack: internalError.stack,
cause: internalError.cause,
}));
// External Channel: Sanitized Problem Details
const sanitizedDetail = isProduction
? 'An unexpected internal server error occurred. Please contact support quoting the correlation identifier.'
: internalError.message;
const payload: ProblemDetails = {
type: 'https://api.example.com/errors/internal-server-error',
title: 'Internal Server Error',
status: 500,
detail: sanitizedDetail,
instance: instanceUri,
...(!isProduction && internalError.stack ? { debugStack: internalError.stack.split('\n') } : {}),
};
res.status(500).json(payload);
}
Key Architectural Properties of the Middleware
- Automatic Content Negotiation: Sends
Content-Type: application/problem+json, allowing automated clients to activate standard Problem Details decoders. - Deterministic Correlation: Ensures
X-Request-Idis present on both the HTTP response header and the RFC 9457instancefield. - CWE-209 Immunity: Replaces raw stack traces with safe, static messages whenever
NODE_ENV === 'production', while logging the complete causal chain internally. - Header Synchronization: Automatically sets protocol headers like
Retry-Afteron 429 errors based on the domain exception payload.
Common Anti-Patterns in API Error Design
Even experienced engineering teams can fall into subtle design traps when implementing error systems. Review the following anti-patterns during API code reviews:
1. The "HTTP 200 with Embedded Error" Pattern
Returning HTTP 200 OK with { "success": false, "error": "Unauthorized" } cripples standard web architecture. Reverse proxies cannot cache successful responses conditionally; API gateways cannot track error budgets; circuit breakers cannot count consecutive failures; and frontend fetch() wrappers do not throw, forcing developers to write manual boilerplate status checks inside every single UI component.
2. Polymorphic Error Payloads
An API where /users returns { "errors": ["Invalid name"] }, /orders returns { "error": "Invalid quantity" }, and /billing returns { "message": "Failed", "code": 50 } creates immense developer friction. Client SDK generators (like OpenAPI / Swagger Codegen) generate messy union types that complicate client-side state handling.
3. Relying on Freeform Text for Control Flow
Never expect client applications to inspect detail or message strings to drive logic. If a client needs to branch based on an error condition (such as "card expired" vs "insufficient funds"), provide an immutable, machine-readable discriminator in type or a structured code property.
4. Naked Exception Propagation
Allowing database or ORM exceptions (such as PrismaClientKnownRequestError or Knex.TimeoutError) to bubble directly up to HTTP clients leaks critical infrastructure details and couples client integration tests to the backend's specific database engine.
Production Verification Checklist
Before releasing or refactoring an API's error handling infrastructure, verify the system against this production readiness checklist:
- [ ] RFC 9457 Conformance: All non-2xx responses deliver valid
application/problem+jsonbodies containingtype,title,status,detail, andinstance. - [ ] Status Code Alignment: The
statusmember in the JSON payload matches the HTTP response status line exactly. - [ ] Validation Precision: Schema validation failures return
422 Unprocessable Contentwith structuredinvalid-paramsarrays identifying individual field names and validation constraints. - [ ] Syntax vs Semantics: Malformed JSON framing returns
400 Bad Request, whereas semantically invalid payloads return422 Unprocessable Content. - [ ] Auth Header Compliance: All
401 Unauthorizedresponses include a standardWWW-Authenticateheader specifying the challenge scheme. - [ ] Rate Limiting Headers: All
429 Too Many Requestsresponses include an integer or HTTP-dateRetry-Afterheader. - [ ] CWE-209 Sanitization: In production mode (
NODE_ENV === 'production'), internal 500 errors contain no database table names, SQL strings, or stack traces. - [ ] Correlation Tracking: Every error response includes an immutable identifier in the
instanceURI and theX-Request-Idheader, matched to internal structured logs. - [ ] Idempotency Protection: Mutating endpoints (
POST,PATCH) supportIdempotency-Keyheaders to protect clients against duplicate execution during network retry storms. - [ ] Jittered Client Retries: Internal background workers and recommended client SDK guidelines use Exponential Backoff with Full Jitter rather than linear or fixed retry intervals.
Conclusion
API error handling is not an afterthought to be completed once the happy path is operational; it is a fundamental architectural contract that governs system stability, developer experience, and operational resilience.
By standardizing on RFC 9457 Problem Details, enforcing semantic HTTP status codes, maintaining strict domain-transport boundaries, and sanitizing public responses against CWE-209 data leakage, teams can transform error responses from frustrating stumbling blocks into reliable, observable interfaces that empower both client developers and backend operations.


