Error Handling in APIs
Error Response Design
Read aloud is unavailable in this browser.
Errors as Communication, Not Just Failures
An API's error responses reveal more about its quality than its success responses ever could. When everything works, any API feels easy. When things break—and they always break—the error response determines whether consumers can recover gracefully or spiral into frustrated debugging sessions.
Poorly designed error responses create cascading problems: users see cryptic messages, developers can't diagnose issues, support tickets pile up, and integration partners lose confidence in your service. Well-designed error responses transform failures into opportunities for recovery, learning, and even increased trust.
Error response design isn't an afterthought—it's a core API design discipline that separates professional-grade APIs from amateur ones.
By the end of this page, you will understand how to structure error responses for maximum utility, choose appropriate error codes and messages, include actionable metadata, design for different consumer types (machines vs humans), and apply these principles across different API styles.
Anatomy of an Excellent Error Response
A well-designed error response answers four fundamental questions that every consumer—whether human or machine—needs answered:
- What happened? — A clear, stable identifier for the error type
- Why did it happen? — Human-readable explanation of the cause
- Where did it happen? — Location or context of the failure
- What can I do about it? — Actionable guidance for recovery
Let's examine how these manifest in a complete error response structure:
123456789101112131415161718192021222324252627282930313233
{ // WHAT HAPPENED: Machine-readable error identifier "code": "PAYMENT_DECLINED", // WHY IT HAPPENED: Human-readable explanation "message": "The payment method was declined by the card issuer", // ADDITIONAL CONTEXT: Structured details "details": { "reason": "insufficient_funds", "declineCode": "51", "cardBrand": "visa", "lastFourDigits": "4242" }, // WHERE IT HAPPENED: Trace information "requestId": "req_a1b2c3d4e5f6", "timestamp": "2024-01-15T14:30:00.000Z", // WHAT CAN I DO ABOUT IT: Actionable guidance "recoveryOptions": { "retryable": false, "suggestedActions": [ "Try a different payment method", "Contact your card issuer for details" ], "helpUrl": "https://docs.example.com/errors/payment-declined" }, // MACHINE-READABLE CATEGORIZATION "type": "client_error", "category": "payment"}- •Error Code — A stable, machine-parseable identifier that code can switch on. Never changes meaning once published.
- •Human Message — Clear, non-technical explanation suitable for display to end users (or developers reading logs).
- •Technical Details — Structured data providing implementation-relevant context. May include field names, constraint values, etc.
- •Request Context — Request ID for correlation, timestamp for debugging. Essential for support escalation.
- •Recovery Guidance — Whether retry makes sense, alternative approaches, links to documentation.
- •Categorization — Error type/category enabling consistent handling across error families.
Designing Error Codes
Error codes are the stable backbone of error handling. Unlike messages (which may be localized or refined), codes are contracts. Get them right from the start—changing them later breaks consumers.
Properties of Well-Designed Error Codes:
- •Unique and Stable — Each error code represents exactly one condition and never changes meaning.
INVALID_EMAILalways means email validation failed. - •Hierarchical/Structured — Use prefixes or namespaces for categorization:
PAYMENT.DECLINED,AUTH.TOKEN_EXPIRED,VALIDATION.FIELD_REQUIRED. - •Self-Descriptive — Codes should be readable without lookup tables.
USER_NOT_FOUNDbeatsERROR_404_A. - •Granular Enough for Handling — Consumers should be able to differentiate conditions they handle differently. Don't collapse distinct errors into one code.
- •Not Too Granular — Avoid exploding into thousands of codes. Balance specificity with manageability.
- •Format Consistent — Choose a format (SCREAMING_SNAKE_CASE, kebab-case, PascalCase) and apply universally.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// GOOD: Hierarchical, descriptive, stable error codesenum ErrorCode { // Authentication domain AUTH_TOKEN_EXPIRED = "AUTH.TOKEN_EXPIRED", AUTH_TOKEN_INVALID = "AUTH.TOKEN_INVALID", AUTH_TOKEN_MISSING = "AUTH.TOKEN_MISSING", AUTH_INSUFFICIENT_PERMISSIONS = "AUTH.INSUFFICIENT_PERMISSIONS", // User domain USER_NOT_FOUND = "USER.NOT_FOUND", USER_ALREADY_EXISTS = "USER.ALREADY_EXISTS", USER_EMAIL_NOT_VERIFIED = "USER.EMAIL_NOT_VERIFIED", USER_ACCOUNT_SUSPENDED = "USER.ACCOUNT_SUSPENDED", // Validation domain VALIDATION_FIELD_REQUIRED = "VALIDATION.FIELD_REQUIRED", VALIDATION_FIELD_INVALID = "VALIDATION.FIELD_INVALID", VALIDATION_FIELD_TOO_LONG = "VALIDATION.FIELD_TOO_LONG", VALIDATION_FIELD_FORMAT = "VALIDATION.FIELD_FORMAT", // Payment domain PAYMENT_DECLINED = "PAYMENT.DECLINED", PAYMENT_EXPIRED_CARD = "PAYMENT.EXPIRED_CARD", PAYMENT_INSUFFICIENT_FUNDS = "PAYMENT.INSUFFICIENT_FUNDS", PAYMENT_FRAUD_DETECTED = "PAYMENT.FRAUD_DETECTED", // Rate limiting domain RATE_LIMIT_EXCEEDED = "RATE_LIMIT.EXCEEDED", RATE_LIMIT_QUOTA_EXHAUSTED = "RATE_LIMIT.QUOTA_EXHAUSTED", // Generic INTERNAL_ERROR = "INTERNAL.ERROR", SERVICE_UNAVAILABLE = "SERVICE.UNAVAILABLE",} // BAD: Vague, unstable, or confusing error codesenum BadErrorCodes { // Too vague—what kind of error? ERROR = "ERROR", FAILED = "FAILED", INVALID = "INVALID", // Exposes implementation details SQL_EXCEPTION = "SQL_EXCEPTION", NULL_POINTER = "NULL_POINTER", // Numeric codes lose meaning E001 = "E001", E002 = "E002", // Inconsistent formatting UserNotFound = "UserNotFound", payment_failed = "payment_failed", CARD_ERROR = "CARD_ERROR",}Once you publish an error code, consumers will hardcode it into their error handling. Changing USER_NOT_FOUND to USER.NOT_FOUND breaks their code. Plan your code structure carefully before release, and treat codes as versioned contracts.
Crafting Error Messages
Error messages serve humans—end users, developers debugging, and support agents investigating. Unlike codes (stable contracts), messages can and should be refined for clarity over time.
- •Clear, specific, and actionable
- •Written for the intended audience
- •Includes what went wrong
- •Suggests how to fix it
- •Uses consistent terminology
- •Avoids technical jargon for user-facing errors
- •Generic: 'An error occurred'
- •Technical: 'SQLException at line 42'
- •Blaming: 'You entered invalid data'
- •Leaking internals: 'Redis connection refused'
- •Unhelpful: 'Please try again later'
- •Inconsistent terminology
| Poor Message | Improved Message | Why It's Better |
|---|---|---|
| Error 500 | We couldn't process your request. Please try again, or contact support with reference #ABC123. | Actionable, includes support reference |
| Invalid input | The email address 'notanemail' is not valid. Please enter an address like 'name@example.com'. | Specific, shows the value, provides example |
| Unauthorized | Your session has expired. Please sign in again to continue. | Explains cause, guides to solution |
| Record not found | No order was found with ID 'ORD-12345'. Check the order ID and try again. | Confirms what was searched, next step |
| Rate limit exceeded | You've made too many requests. Please wait 30 seconds before trying again. | Specific wait time, actionable |
| Payment failed | The card ending in 4242 was declined. Please try a different payment method. | Identifies which card, suggests alternative |
123456789101112131415161718192021222324252627282930313233
// Message templates with dynamic detailsconst errorMessages = { // Include the problematic value VALIDATION_FIELD_REQUIRED: (field: string) => `The ${field} field is required`, // Show what was received vs expected VALIDATION_FIELD_FORMAT: (field: string, received: string, expected: string) => `Invalid ${field} format: '${received}' does not match expected pattern '${expected}'`, // Include limits VALIDATION_FIELD_TOO_LONG: (field: string, max: number, actual: number) => `${field} exceeds maximum length of ${max} characters (received ${actual})`, // Time-based info AUTH_TOKEN_EXPIRED: (expiredAt: Date) => `Your session expired at ${formatDate(expiredAt)}. Please sign in again.`, // Retry guidance RATE_LIMIT_EXCEEDED: (retryAfter: number) => `Rate limit exceeded. Please wait ${retryAfter} seconds before retrying.`, // User-friendly payment messages PAYMENT_INSUFFICIENT_FUNDS: (cardLast4: string) => `The card ending in ${cardLast4} was declined due to insufficient funds. Please use a different card or add funds.`,} as const; // Usagethrow new ApiError({ code: ErrorCode.VALIDATION_FIELD_TOO_LONG, message: errorMessages.VALIDATION_FIELD_TOO_LONG('description', 500, 1247), details: { field: 'description', maxLength: 500, actualLength: 1247 }});Consider providing both a user-friendly message and a developer-friendly developerMessage. End users see 'We couldn't find that order'; developers see 'Query returned 0 results for order_id=ORD-12345 in partition=orders-2024'. Same error, different audiences.
Error Details and Metadata
Beyond codes and messages, structured details enable sophisticated error handling. The right metadata helps consumers programmatically respond to specific conditions.
123456789101112131415161718192021222324252627282930
// Validation errors: per-field details{ "code": "VALIDATION.MULTIPLE_ERRORS", "message": "Request validation failed", "details": { "errors": [ { "field": "email", "code": "VALIDATION.FIELD_FORMAT", "message": "Invalid email format", "value": "not-an-email", "constraint": "^[\\w.-]+@[\\w.-]+\\.\\w+$" }, { "field": "age", "code": "VALIDATION.FIELD_RANGE", "message": "Age must be between 18 and 120", "value": 15, "constraint": { "min": 18, "max": 120 } }, { "field": "items[2].quantity", "code": "VALIDATION.FIELD_REQUIRED", "message": "Quantity is required", "value": null, "path": ["items", 2, "quantity"] } ] }}- •Include what consumers need to act — Field names for validation errors, timing for rate limits, conflicting values for duplicates.
- •Provide paths for nested fields — Array indices, object paths.
items[2].quantityis clearer thanquantity. - •Include constraints when relevant — If a field failed a length check, include the limit. If timing matters, include timestamps.
- •Indicate retryability — Can this request succeed if retried? With what backoff? This enables automatic retry logic.
- •Link to related resources — URLs to documentation, related entities, or alternative actions.
- •Don't leak sensitive data — Never include passwords, tokens, or PII in error details, even if they caused the error.
HTTP Status Codes: Choosing the Right Code
For HTTP APIs, status codes are the first signal of error type. Consumers check status codes before parsing response bodies. Choosing correctly enables proper handling at the HTTP layer.
| Status Code | Meaning | When to Use |
|---|---|---|
| 400 Bad Request | Malformed request syntax | Invalid JSON, missing required headers, unparseable request body |
| 401 Unauthorized | Authentication required | No credentials, invalid token, expired token |
| 403 Forbidden | Authenticated but not authorized | Valid auth but lacks permission for this resource/action |
| 404 Not Found | Resource doesn't exist | Requested entity not found (or intentionally hidden from unauthorized users) |
| 405 Method Not Allowed | HTTP method not supported | POST to read-only endpoint, DELETE where not permitted |
| 409 Conflict | Request conflicts with current state | Duplicate creation, edit conflict, version mismatch |
| 422 Unprocessable Entity | Syntactically valid but semantically invalid | Business rule violations, validation failures |
| 429 Too Many Requests | Rate limit exceeded | Quota exhausted, too many requests in time window |
| 500 Internal Server Error | Unexpected server error | Unhandled exceptions, bugs. Never intentionally returned. |
| 502 Bad Gateway | Upstream service error | Dependency returned invalid response |
| 503 Service Unavailable | Server temporarily unavailable | Maintenance mode, overloaded, dependency down |
| 504 Gateway Timeout | Upstream timeout | Dependency took too long to respond |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// Map error codes to HTTP status codesconst errorCodeToHttpStatus: Record<string, number> = { // Authentication: 401 'AUTH.TOKEN_EXPIRED': 401, 'AUTH.TOKEN_INVALID': 401, 'AUTH.TOKEN_MISSING': 401, // Authorization: 403 'AUTH.INSUFFICIENT_PERMISSIONS': 403, 'AUTH.ACCOUNT_SUSPENDED': 403, // Not found: 404 'USER.NOT_FOUND': 404, 'ORDER.NOT_FOUND': 404, 'RESOURCE.NOT_FOUND': 404, // Conflicts: 409 'USER.ALREADY_EXISTS': 409, 'ORDER.ALREADY_PROCESSED': 409, 'CONCURRENT_MODIFICATION': 409, // Validation: 422 (or 400) 'VALIDATION.FIELD_REQUIRED': 422, 'VALIDATION.FIELD_INVALID': 422, 'VALIDATION.BUSINESS_RULE': 422, // Rate limiting: 429 'RATE_LIMIT.EXCEEDED': 429, 'RATE_LIMIT.QUOTA_EXHAUSTED': 429, // Server errors: 5xx 'INTERNAL.ERROR': 500, 'SERVICE.DEPENDENCY_FAILED': 502, 'SERVICE.UNAVAILABLE': 503, 'SERVICE.TIMEOUT': 504,}; // Unified error handlerfunction toHttpResponse(error: ApiError): HttpResponse { const statusCode = errorCodeToHttpStatus[error.code] ?? 500; return { status: statusCode, body: { code: error.code, message: error.message, details: error.details, requestId: getCurrentRequestId(), timestamp: new Date().toISOString(), }, headers: buildErrorHeaders(error, statusCode), };} function buildErrorHeaders(error: ApiError, status: number): Record<string, string> { const headers: Record<string, string> = {}; if (status === 429 && error.details?.retryAfter) { headers['Retry-After'] = String(error.details.retryAfter); } if (status === 401) { headers['WWW-Authenticate'] = 'Bearer realm="api"'; } return headers;}Some APIs use 400 for all client errors including validation. Others use 400 for syntactic issues (bad JSON) and 422 for semantic issues (invalid field value). Either is defensible—just be consistent across your entire API.
Error Response Patterns by API Style
Different API styles have different conventions and expectations for error responses. Match your style to consumer expectations.
1234567891011121314151617181920212223242526
// Standard REST error envelopeHTTP/1.1 422 Unprocessable EntityContent-Type: application/problem+json { "type": "https://api.example.com/errors/validation-failed", "title": "Validation Failed", "status": 422, "detail": "The request contains invalid field values", "instance": "/orders/create", "traceId": "abc-123-def-456", "errors": [ { "field": "email", "code": "INVALID_FORMAT", "message": "Must be a valid email address" } ]} // Using RFC 7807 Problem Details standard// - type: URI identifying error type (can be documentation link)// - title: Short human-readable summary// - status: HTTP status code// - detail: Human-readable explanation// - instance: URI of the specific occurrenceSecurity Considerations in Error Responses
Error responses can inadvertently leak sensitive information that aids attackers. Security-conscious error design requires balancing helpfulness with information protection.
- •Stack traces in production
- •Database schema details
- •Internal service names/URLs
- •File system paths
- •SQL queries
- •User enumeration clues
- •Version numbers of vulnerable software
- •Error codes (no internals)
- •User-friendly messages
- •Request IDs for support
- •Field names (validation)
- •Rate limit info
- •Documentation links
- •Suggested actions
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// User enumeration protection// BAD: Different errors reveal user existenceapp.post('/login', (req, res) => { const user = findUser(req.body.email); if (!user) { return res.status(404).json({ error: "User not found" // Reveals email not registered }); } if (!verifyPassword(user, req.body.password)) { return res.status(401).json({ error: "Invalid password" // Confirms email exists }); } // ... login success}); // GOOD: Same error for both casesapp.post('/login', (req, res) => { const user = findUser(req.body.email); const valid = user && verifyPassword(user, req.body.password); if (!valid) { return res.status(401).json({ code: "AUTH.INVALID_CREDENTIALS", message: "Invalid email or password" // No clue which is wrong }); } // ... login success}); // Internal error sanitizationfunction toErrorResponse(error: Error, requestId: string): ApiError { // Log full details internally logger.error('Request failed', { requestId, error: error.message, stack: error.stack, cause: error.cause, }); // Return sanitized response if (error instanceof ApiError) { return error; // Already sanitized } // Unknown errors get generic response return { code: 'INTERNAL.ERROR', message: 'An unexpected error occurred', requestId, // NO stack trace, NO internal details };}It's common to include detailed error information (stack traces, SQL) in development and staging environments while sanitizing production responses. Ensure this toggle is secure—an attacker shouldn't be able to trigger dev mode in production.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.