API Error Handling: Complete Guide for Production APIs
Production-ready error handling patterns: HTTP status codes, error response formats, retry strategies, logging, and monitoring best practices.
๐ก Monitor your APIs โ know when they go down before your users do
Better Stack checks uptime every 30 seconds with instant Slack, email & SMS alerts. Free tier available.
Affiliate link โ we may earn a commission at no extra cost to you
Error handling is where good APIs become great APIs. A well-designed error handling strategy turns debugging nightmares into smooth troubleshooting experiences, reduces support tickets by 60%+, and builds trust with developers integrating your API.
This guide covers production-ready error handling patterns used by Stripe, GitHub, and AWS, with complete TypeScript implementations you can adapt for your API.
Why Error Handling Matters
Poor error handling costs real money:
- Lost development time: Developers spend 30-50% of debugging time deciphering vague errors
- Support burden: Generic errors generate 3x more support tickets than specific ones
- Failed integrations: 40% of API integration failures stem from inadequate error documentation
- User churn: Poor error UX directly impacts customer satisfaction and retention
HTTP Status Codes: The Foundation
Use HTTP status codes correctly. They're the first signal to clients about what went wrong.
Client Errors (4xx)
// 400 Bad Request - Invalid syntax or validation failure
{
"error": {
"code": "validation_error",
"message": "Invalid request parameters",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email"
},
{
"field": "age",
"message": "Must be at least 18",
"value": 15
}
]
}
}
// 401 Unauthorized - Missing or invalid authentication
{
"error": {
"code": "unauthorized",
"message": "Invalid API key",
"hint": "Include your API key in the Authorization header"
}
}
// 403 Forbidden - Valid authentication but insufficient permissions
{
"error": {
"code": "forbidden",
"message": "Insufficient permissions",
"required_scope": "users:write",
"current_scopes": ["users:read"]
}
}
// 404 Not Found - Resource doesn't exist
{
"error": {
"code": "resource_not_found",
"message": "User not found",
"resource_type": "user",
"resource_id": "usr_123"
}
}
// 409 Conflict - State conflict (duplicate resource, version mismatch)
{
"error": {
"code": "duplicate_resource",
"message": "User with this email already exists",
"existing_resource_id": "usr_456"
}
}
// 422 Unprocessable Entity - Valid syntax but semantic errors
{
"error": {
"code": "business_logic_error",
"message": "Cannot refund already refunded payment",
"payment_id": "pay_789",
"current_status": "refunded"
}
}
// 429 Too Many Requests - Rate limit exceeded
{
"error": {
"code": "rate_limit_exceeded",
"message": "API rate limit exceeded",
"limit": 100,
"window": "1m",
"retry_after": 45
}
}Server Errors (5xx)
// 500 Internal Server Error - Unexpected server failure
{
"error": {
"code": "internal_error",
"message": "An unexpected error occurred",
"request_id": "req_abc123",
"hint": "Contact support with this request_id if the issue persists"
}
}
// 502 Bad Gateway - Upstream service failure
{
"error": {
"code": "upstream_service_error",
"message": "Payment processor is temporarily unavailable",
"service": "stripe",
"request_id": "req_def456"
}
}
// 503 Service Unavailable - Temporary overload or maintenance
{
"error": {
"code": "service_unavailable",
"message": "API is temporarily unavailable for maintenance",
"retry_after": 300,
"status_url": "https://status.example.com"
}
}
// 504 Gateway Timeout - Upstream service timeout
{
"error": {
"code": "upstream_timeout",
"message": "Request timed out waiting for external service",
"timeout": 30,
"service": "database"
}
}Error Response Format
Consistent error structure across all endpoints makes integration easier. Here's a battle-tested format:
interface APIError {
error: {
// Machine-readable error code (use for conditional logic)
code: string;
// Human-readable error message (show to users)
message: string;
// Optional: Additional context for debugging
details?: Array<{
field?: string;
message: string;
value?: any;
}>;
// Optional: Hints for resolution
hint?: string;
// Optional: Link to documentation
doc_url?: string;
// Optional: Unique request identifier for support
request_id?: string;
// Optional: For nested errors from upstream services
cause?: APIError;
};
}Real-World Example: Stripe's Error Format
Stripe's error handling is considered the gold standard:
{
"error": {
"type": "card_error",
"code": "card_declined",
"message": "Your card was declined.",
"decline_code": "insufficient_funds",
"charge": "ch_3NmJw92eZvKYlo2C0Dq7tqBi",
"doc_url": "https://stripe.com/docs/error-codes/card-declined"
}
}Server-Side Error Handler Implementation
Centralized error handling with Express middleware:
// Custom error class
class APIError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: any[],
public hint?: string
) {
super(message);
this.name = 'APIError';
}
}
// Error factory functions
export const errors = {
badRequest: (message: string, details?: any[]) =>
new APIError(400, 'bad_request', message, details),
unauthorized: (message = 'Invalid authentication credentials') =>
new APIError(401, 'unauthorized', message),
forbidden: (message = 'Insufficient permissions') =>
new APIError(403, 'forbidden', message),
notFound: (resourceType: string, resourceId: string) =>
new APIError(
404,
'resource_not_found',
`${resourceType} not found`,
undefined,
`No ${resourceType} exists with id ${resourceId}`
),
conflict: (message: string) =>
new APIError(409, 'conflict', message),
validationError: (fields: Array<{ field: string; message: string }>) =>
new APIError(
422,
'validation_error',
'Validation failed',
fields
),
rateLimit: (limit: number, window: string, retryAfter: number) =>
new APIError(
429,
'rate_limit_exceeded',
`Rate limit of ${limit} requests per ${window} exceeded`,
[{ limit, window, retry_after: retryAfter }]
),
internal: (message = 'Internal server error') =>
new APIError(500, 'internal_error', message),
serviceUnavailable: (service: string) =>
new APIError(
503,
'service_unavailable',
`${service} is temporarily unavailable`
),
};
// Express error handler middleware
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
// Generate unique request ID for tracking
const requestId = req.headers['x-request-id'] ||
`req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Log error with context
console.error({
requestId,
method: req.method,
path: req.path,
error: {
name: err.name,
message: err.message,
stack: err.stack,
},
});
// Handle known API errors
if (err instanceof APIError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
hint: err.hint,
request_id: requestId,
},
});
}
// Handle Zod validation errors
if (err.name === 'ZodError') {
const zodError = err as any;
return res.status(422).json({
error: {
code: 'validation_error',
message: 'Validation failed',
details: zodError.errors.map((e: any) => ({
field: e.path.join('.'),
message: e.message,
value: e.input,
})),
request_id: requestId,
},
});
}
// Handle JWT errors
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
error: {
code: 'invalid_token',
message: 'Invalid or expired token',
request_id: requestId,
},
});
}
// Handle Prisma errors
if (err.name === 'PrismaClientKnownRequestError') {
const prismaError = err as any;
// Unique constraint violation
if (prismaError.code === 'P2002') {
return res.status(409).json({
error: {
code: 'duplicate_resource',
message: `Resource with this ${prismaError.meta?.target?.[0]} already exists`,
request_id: requestId,
},
});
}
// Foreign key constraint violation
if (prismaError.code === 'P2003') {
return res.status(400).json({
error: {
code: 'invalid_reference',
message: 'Referenced resource does not exist',
request_id: requestId,
},
});
}
}
// Unknown errors - don't leak internal details in production
const isDevelopment = process.env.NODE_ENV === 'development';
res.status(500).json({
error: {
code: 'internal_error',
message: 'An unexpected error occurred',
request_id: requestId,
...(isDevelopment && {
debug: {
name: err.name,
message: err.message,
stack: err.stack,
},
}),
},
});
}
// Usage in routes
app.post('/api/users', async (req, res, next) => {
try {
const { email, name } = req.body;
// Validation
if (!email || !name) {
throw errors.validationError([
!email && { field: 'email', message: 'Email is required' },
!name && { field: 'name', message: 'Name is required' },
].filter(Boolean));
}
// Check for duplicate
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
throw errors.conflict('User with this email already exists');
}
// Create user
const user = await db.user.create({ data: { email, name } });
res.status(201).json({ user });
} catch (error) {
next(error); // Pass to error handler middleware
}
});
// Register error handler (must be last)
app.use(errorHandler);Client-Side Error Handling
Client libraries should handle errors gracefully and provide clear feedback:
class APIClient {
private baseURL: string;
private apiKey: string;
constructor(apiKey: string, baseURL = 'https://api.example.com') {
this.apiKey = apiKey;
this.baseURL = baseURL;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
...options.headers,
},
});
// Success
if (response.ok) {
return await response.json();
}
// Parse error response
const errorData = await response.json();
// Throw specific error types based on status code
switch (response.status) {
case 400:
throw new ValidationError(errorData.error);
case 401:
throw new AuthenticationError(errorData.error);
case 403:
throw new PermissionError(errorData.error);
case 404:
throw new NotFoundError(errorData.error);
case 409:
throw new ConflictError(errorData.error);
case 429:
throw new RateLimitError(errorData.error);
case 500:
case 502:
case 503:
case 504:
throw new ServerError(errorData.error);
default:
throw new APIError(errorData.error);
}
} catch (error) {
// Network errors, timeout, etc.
if (error instanceof TypeError) {
throw new NetworkError('Network request failed');
}
// Re-throw API errors
throw error;
}
}
async getUser(userId: string) {
return this.request<User>(`/users/${userId}`);
}
}
// Custom error classes
class APIError extends Error {
constructor(public error: any) {
super(error.message);
this.name = 'APIError';
}
}
class ValidationError extends APIError {
get fields() {
return this.error.details || [];
}
}
class AuthenticationError extends APIError {}
class PermissionError extends APIError {}
class NotFoundError extends APIError {}
class ConflictError extends APIError {}
class RateLimitError extends APIError {
get retryAfter() {
return this.error.details?.[0]?.retry_after || 60;
}
}
class ServerError extends APIError {}
class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
// Usage with proper error handling
async function displayUser(userId: string) {
try {
const user = await apiClient.getUser(userId);
console.log('User:', user);
} catch (error) {
if (error instanceof ValidationError) {
console.error('Validation errors:');
error.fields.forEach(field => {
console.error(` - ${field.field}: ${field.message}`);
});
} else if (error instanceof AuthenticationError) {
console.error('Authentication failed. Please check your API key.');
// Redirect to login
} else if (error instanceof NotFoundError) {
console.error(`User ${userId} not found`);
// Show 404 UI
} else if (error instanceof RateLimitError) {
console.error(`Rate limited. Retry after ${error.retryAfter}s`);
// Schedule retry
} else if (error instanceof ServerError) {
console.error('Server error. Please try again later.');
// Show error toast
} else if (error instanceof NetworkError) {
console.error('Network error. Please check your connection.');
// Show offline UI
} else {
console.error('Unexpected error:', error);
// Log to error tracking service
}
}
}Retry Strategies
Not all errors are permanent. Implement smart retry logic for transient failures:
// Exponential backoff with jitter
async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: {
maxRetries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
retryableErrors?: Array<new (...args: any[]) => Error>;
} = {}
): Promise<T> {
const {
maxRetries = 3,
baseDelayMs = 1000,
maxDelayMs = 30000,
retryableErrors = [ServerError, NetworkError, RateLimitError],
} = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
// Don't retry client errors (4xx except 429)
const isRetryable = retryableErrors.some(
ErrorClass => error instanceof ErrorClass
);
if (!isRetryable || attempt === maxRetries) {
throw error;
}
// Calculate delay with exponential backoff + jitter
let delay = Math.min(
baseDelayMs * Math.pow(2, attempt),
maxDelayMs
);
// Add jitter (ยฑ25% randomness)
delay = delay * (0.75 + Math.random() * 0.5);
// Honor rate limit retry-after if present
if (error instanceof RateLimitError) {
delay = error.retryAfter * 1000;
}
console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${Math.round(delay)}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Max retries exceeded');
}
// Usage
const user = await retryWithBackoff(() => apiClient.getUser('usr_123'));When to Retry
Always retry:
- 500 Internal Server Error
- 502 Bad Gateway
- 503 Service Unavailable
- 504 Gateway Timeout
- Network timeouts
Sometimes retry:
- 429 Too Many Requests (honor retry-after header)
- 408 Request Timeout
Never retry:
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 409 Conflict
- 422 Unprocessable Entity
Logging and Monitoring
Production error handling requires visibility. Implement structured logging:
import winston from 'winston';
// Structured logger
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'api' },
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
// Enhanced error handler with logging
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
const requestId = req.headers['x-request-id'] || generateRequestId();
// Build error context
const errorContext = {
requestId,
timestamp: new Date().toISOString(),
method: req.method,
path: req.path,
query: req.query,
user: req.user?.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
error: {
name: err.name,
message: err.message,
code: (err as any).code,
statusCode: (err as any).statusCode,
stack: err.stack,
},
};
// Log based on severity
if (err instanceof APIError) {
if (err.statusCode >= 500) {
// Server errors are critical
logger.error('Server error', errorContext);
} else if (err.statusCode === 429) {
// Rate limits are warnings
logger.warn('Rate limit exceeded', errorContext);
} else {
// Client errors are info
logger.info('Client error', errorContext);
}
} else {
// Unknown errors are critical
logger.error('Unexpected error', errorContext);
}
// Send to error tracking service (Sentry, Datadog, etc.)
if (process.env.NODE_ENV === 'production') {
captureException(err, {
tags: {
request_id: requestId,
endpoint: req.path,
},
user: req.user ? { id: req.user.id } : undefined,
});
}
// Return error response...
}Monitor Error Rates
Track error rates to detect issues early:
- Error rate by endpoint: Which endpoints are failing?
- Error rate by status code: Are 5xx errors spiking?
- Error rate by user: Is one user hitting edge cases?
- Error rate by time: Are errors correlated with deployments?
Set up alerts:
- 5xx error rate > 1% sustained for 5 minutes โ critical alert
- 4xx error rate > 10% sustained for 5 minutes โ warning
- Any endpoint with 100% error rate โ immediate alert
Use services like Datadog, New Relic, or Sentry for error tracking and alerting.
Secure your API keys and credentials
Leaked API keys in error logs are a top security risk. Use 1Password to manage secrets across your team โ never hardcode credentials again.
Try 1Password Free โError Documentation
Document every possible error in your API docs. Developers need to know:
- Error code: Machine-readable identifier
- Status code: HTTP status
- Message: Human-readable description
- Cause: What triggers this error
- Resolution: How to fix it
- Example: Sample error response
### Error: validation_error
**Status Code:** 422 Unprocessable Entity
**Description:** The request body contains invalid data that failed validation.
**Causes:**
- Missing required fields
- Invalid field types
- Field values outside allowed ranges
- Invalid format (e.g., malformed email)
**Resolution:**
1. Check the `details` array in the error response
2. Each detail contains `field`, `message`, and rejected `value`
3. Fix the invalid fields and retry
**Example:**
```json
{
"error": {
"code": "validation_error",
"message": "Validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"value": "not-an-email"
}
],
"request_id": "req_abc123"
}
}
```
**Related:**
- [Input Validation Guide](#)
- [Field Reference](#)Real-World Error Handling Examples
GitHub API
GitHub's API provides excellent error context:
// Rate limit exceeded
{
"message": "API rate limit exceeded for user ID 12345.",
"documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"
}
// Validation failed
{
"message": "Validation Failed",
"errors": [
{
"resource": "Issue",
"field": "title",
"code": "missing_field"
}
],
"documentation_url": "https://docs.github.com/rest/reference/issues#create-an-issue"
}AWS API
AWS APIs use detailed error codes:
// S3 NoSuchKey error
{
"Error": {
"Code": "NoSuchKey",
"Message": "The specified key does not exist.",
"Key": "example-object",
"RequestId": "4442587FB7D0A2F9",
"HostId": "..."
}
}
// DynamoDB ConditionalCheckFailedException
{
"__type": "com.amazon.coral.validate#ValidationException",
"message": "The conditional request failed"
}Testing Error Handling
Test error paths just as thoroughly as success paths:
import request from 'supertest';
import { app } from '../app';
describe('Error Handling', () => {
describe('POST /api/users', () => {
it('returns 422 for missing required fields', async () => {
const response = await request(app)
.post('/api/users')
.send({})
.expect(422);
expect(response.body.error.code).toBe('validation_error');
expect(response.body.error.details).toHaveLength(2);
expect(response.body.error.details[0].field).toBe('email');
});
it('returns 409 for duplicate email', async () => {
// Create user
await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test' })
.expect(201);
// Try to create duplicate
const response = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test 2' })
.expect(409);
expect(response.body.error.code).toBe('duplicate_resource');
});
it('returns 401 for missing authentication', async () => {
const response = await request(app)
.post('/api/users')
.set('Authorization', '') // Remove auth header
.send({ email: 'test@example.com', name: 'Test' })
.expect(401);
expect(response.body.error.code).toBe('unauthorized');
});
it('includes request_id in all error responses', async () => {
const response = await request(app)
.post('/api/users')
.send({})
.expect(422);
expect(response.body.error.request_id).toMatch(/^req_/);
});
});
describe('Error Handler Middleware', () => {
it('converts Zod errors to validation_error', async () => {
// Test with Zod validation
});
it('converts Prisma unique constraint to duplicate_resource', async () => {
// Test with database constraint violation
});
it('sanitizes error messages in production', async () => {
process.env.NODE_ENV = 'production';
// Trigger an unexpected error
const response = await request(app)
.get('/api/test-error')
.expect(500);
expect(response.body.error.message).toBe('An unexpected error occurred');
expect(response.body.error.debug).toBeUndefined();
});
});
});Common Mistakes to Avoid
1. Leaking Sensitive Information
โ Don't do this:
// Exposes internal database details
{
"error": {
"message": "Error: column 'password_hash' does not exist",
"stack": "Error: column 'password_hash' does not exist\n at Connection.query (/app/node_modules/pg/lib/connection.js:159:17)\n at /app/src/db.ts:42:19"
}
}โ Do this instead:
{
"error": {
"code": "internal_error",
"message": "An unexpected error occurred",
"request_id": "req_abc123"
}
}2. Generic Error Messages
โ Don't do this:
{
"error": "Something went wrong"
}โ Do this instead:
{
"error": {
"code": "invalid_email",
"message": "Email address is invalid",
"field": "email",
"value": "not-an-email",
"hint": "Email must contain @ symbol and valid domain"
}
}3. Inconsistent Error Format
Use the same error structure across all endpoints. Don't mix formats:
// โ Endpoint A
{ "error": "Invalid email" }
// โ Endpoint B
{ "message": "User not found", "code": 404 }
// โ Endpoint C
{ "success": false, "error": { "type": "validation" } }
// โ
All endpoints
{
"error": {
"code": "...",
"message": "...",
"details": [...],
"request_id": "..."
}
}4. Not Logging Error Context
Log enough context to debug issues:
// โ Insufficient logging
console.error(error.message);
// โ
Comprehensive logging
logger.error('Request failed', {
requestId: req.id,
method: req.method,
path: req.path,
userId: req.user?.id,
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});5. Retrying Non-Idempotent Operations
Don't automatically retry operations that modify state:
// โ Dangerous - could charge user multiple times
await retryWithBackoff(() =>
stripe.charges.create({ amount: 1000, currency: 'usd', source: token })
);
// โ
Safe - use idempotency key
await retryWithBackoff(() =>
stripe.charges.create({
amount: 1000,
currency: 'usd',
source: token,
idempotency_key: `charge_${orderId}` // Same key = same charge
})
);Protect your developer identity online
Developers are high-value targets for social engineering. Optery scans 400+ data brokers and removes your personal information โ reduce your attack surface.
Scan Free with Optery โError Handling Checklist
Use this checklist to audit your API's error handling:
- โ Use correct HTTP status codes (4xx for client errors, 5xx for server errors)
- โ Return consistent error format across all endpoints
- โ Include machine-readable error codes
- โ Include human-readable error messages
- โ Provide actionable error details (which field failed, why, how to fix)
- โ Include request_id for support tracking
- โ Link to documentation for complex errors
- โ Implement retry logic with exponential backoff for transient failures
- โ Honor rate limit retry-after headers
- โ Never retry non-idempotent operations without idempotency keys
- โ Log errors with sufficient context for debugging
- โ Monitor error rates and set up alerts
- โ Sanitize error messages in production (no stack traces, internal paths, etc.)
- โ Document all possible errors in API docs
- โ Test error paths thoroughly
- โ Provide clear hints for resolution
Next Steps
Now that you have production-ready error handling, consider:
- Monitoring API dependencies with dependency monitoring strategies
- Implementing rate limiting to prevent abuse
- Building reliable webhooks for event-driven integrations
- Setting up health checks and status pages for API uptime visibility
Track the status of key API dependencies at API Status Check.
Monitor your APIs and infrastructure in real-time
Better Stack combines uptime monitoring, incident management, and log aggregation. Free tier includes 10 monitors with 3-minute checks.
Try Better Stack Free โ๐ Tools We Use & Recommend
Tested across our own infrastructure monitoring 200+ APIs daily
Uptime Monitoring & Incident Management
Used by 100,000+ websites
Monitors your APIs every 30 seconds. Instant alerts via Slack, email, SMS, and phone calls when something goes down.
โWe use Better Stack to monitor every API on this site. It caught 23 outages last month before users reported them.โ
Secrets Management & Developer Security
Trusted by 150,000+ businesses
Manage API keys, database passwords, and service tokens with CLI integration and automatic rotation.
โAfter covering dozens of outages caused by leaked credentials, we recommend every team use a secrets manager.โ
SEO & Site Performance Monitoring
Used by 10M+ marketers
Track your site health, uptime, search rankings, and competitor movements from one dashboard.
โWe use SEMrush to track how our API status pages rank and catch site health issues early.โ