Back to Blog
APIError HandlingRESTBackend

API Error Handling Patterns

Handle errors consistently in APIs. From error types to status codes to client-friendly responses.

B
Bootspring Team
Engineering
August 5, 2022
6 min read

Consistent error handling makes APIs predictable and debuggable. Here's how to implement it effectively.

Error Response Structure#

1// Standard error response 2interface ErrorResponse { 3 error: { 4 code: string; // Machine-readable error code 5 message: string; // Human-readable message 6 details?: unknown; // Additional context 7 requestId?: string; // For debugging 8 timestamp?: string; // When error occurred 9 }; 10} 11 12// Example responses 13{ 14 "error": { 15 "code": "VALIDATION_ERROR", 16 "message": "Invalid request body", 17 "details": { 18 "fields": [ 19 { "field": "email", "message": "Invalid email format" }, 20 { "field": "age", "message": "Must be a positive number" } 21 ] 22 }, 23 "requestId": "req_abc123", 24 "timestamp": "2024-01-15T10:30:00Z" 25 } 26} 27 28{ 29 "error": { 30 "code": "NOT_FOUND", 31 "message": "User not found", 32 "requestId": "req_def456" 33 } 34}

Custom Error Classes#

1// Base application error 2class AppError extends Error { 3 constructor( 4 public code: string, 5 public message: string, 6 public statusCode: number, 7 public details?: unknown 8 ) { 9 super(message); 10 this.name = 'AppError'; 11 Error.captureStackTrace(this, this.constructor); 12 } 13 14 toJSON(): ErrorResponse { 15 return { 16 error: { 17 code: this.code, 18 message: this.message, 19 details: this.details, 20 }, 21 }; 22 } 23} 24 25// Specific error types 26class ValidationError extends AppError { 27 constructor(details: { field: string; message: string }[]) { 28 super('VALIDATION_ERROR', 'Validation failed', 400, { fields: details }); 29 } 30} 31 32class NotFoundError extends AppError { 33 constructor(resource: string, id?: string) { 34 super( 35 'NOT_FOUND', 36 id ? `${resource} with id ${id} not found` : `${resource} not found`, 37 404 38 ); 39 } 40} 41 42class UnauthorizedError extends AppError { 43 constructor(message = 'Authentication required') { 44 super('UNAUTHORIZED', message, 401); 45 } 46} 47 48class ForbiddenError extends AppError { 49 constructor(message = 'Access denied') { 50 super('FORBIDDEN', message, 403); 51 } 52} 53 54class ConflictError extends AppError { 55 constructor(message: string) { 56 super('CONFLICT', message, 409); 57 } 58} 59 60class RateLimitError extends AppError { 61 constructor(retryAfter?: number) { 62 super('RATE_LIMITED', 'Too many requests', 429, { retryAfter }); 63 } 64}

Express Error Middleware#

1import { Request, Response, NextFunction } from 'express'; 2 3// Error handler middleware 4function errorHandler( 5 err: Error, 6 req: Request, 7 res: Response, 8 next: NextFunction 9): void { 10 const requestId = req.headers['x-request-id'] || crypto.randomUUID(); 11 12 // Log error 13 logger.error('Request error', { 14 requestId, 15 error: err.message, 16 stack: err.stack, 17 path: req.path, 18 method: req.method, 19 }); 20 21 // Handle known errors 22 if (err instanceof AppError) { 23 const response = err.toJSON(); 24 response.error.requestId = requestId; 25 response.error.timestamp = new Date().toISOString(); 26 27 res.status(err.statusCode).json(response); 28 return; 29 } 30 31 // Handle Zod validation errors 32 if (err.name === 'ZodError') { 33 const zodError = err as z.ZodError; 34 res.status(400).json({ 35 error: { 36 code: 'VALIDATION_ERROR', 37 message: 'Invalid request', 38 details: { 39 fields: zodError.errors.map((e) => ({ 40 field: e.path.join('.'), 41 message: e.message, 42 })), 43 }, 44 requestId, 45 }, 46 }); 47 return; 48 } 49 50 // Handle Prisma errors 51 if (err.name === 'PrismaClientKnownRequestError') { 52 const prismaError = err as PrismaClientKnownRequestError; 53 54 if (prismaError.code === 'P2002') { 55 res.status(409).json({ 56 error: { 57 code: 'CONFLICT', 58 message: 'Resource already exists', 59 requestId, 60 }, 61 }); 62 return; 63 } 64 65 if (prismaError.code === 'P2025') { 66 res.status(404).json({ 67 error: { 68 code: 'NOT_FOUND', 69 message: 'Resource not found', 70 requestId, 71 }, 72 }); 73 return; 74 } 75 } 76 77 // Unknown errors - don't leak details 78 res.status(500).json({ 79 error: { 80 code: 'INTERNAL_ERROR', 81 message: 'An unexpected error occurred', 82 requestId, 83 timestamp: new Date().toISOString(), 84 }, 85 }); 86} 87 88// Async handler wrapper 89function asyncHandler( 90 fn: (req: Request, res: Response, next: NextFunction) => Promise<any> 91) { 92 return (req: Request, res: Response, next: NextFunction) => { 93 Promise.resolve(fn(req, res, next)).catch(next); 94 }; 95} 96 97// Usage 98app.get( 99 '/users/:id', 100 asyncHandler(async (req, res) => { 101 const user = await userService.findById(req.params.id); 102 if (!user) { 103 throw new NotFoundError('User', req.params.id); 104 } 105 res.json(user); 106 }) 107); 108 109app.use(errorHandler);

HTTP Status Codes#

1// Status code guidelines 2const statusCodes = { 3 // Success 4 200: 'OK - Request succeeded', 5 201: 'Created - Resource created', 6 204: 'No Content - Success with no body', 7 8 // Client errors 9 400: 'Bad Request - Invalid syntax', 10 401: 'Unauthorized - Authentication required', 11 403: 'Forbidden - Not allowed', 12 404: 'Not Found - Resource doesn\'t exist', 13 409: 'Conflict - Resource conflict', 14 422: 'Unprocessable - Semantic errors', 15 429: 'Too Many Requests - Rate limited', 16 17 // Server errors 18 500: 'Internal Server Error', 19 502: 'Bad Gateway - Upstream error', 20 503: 'Service Unavailable - Temporarily down', 21 504: 'Gateway Timeout - Upstream timeout', 22}; 23 24// When to use which 25function getStatusCode(error: AppError): number { 26 switch (error.code) { 27 case 'VALIDATION_ERROR': 28 return 400; // or 422 for semantic validation 29 case 'UNAUTHORIZED': 30 return 401; 31 case 'FORBIDDEN': 32 return 403; 33 case 'NOT_FOUND': 34 return 404; 35 case 'CONFLICT': 36 return 409; 37 case 'RATE_LIMITED': 38 return 429; 39 default: 40 return 500; 41 } 42}

Error Codes#

1// Define all error codes 2const ErrorCodes = { 3 // Auth errors 4 UNAUTHORIZED: 'UNAUTHORIZED', 5 INVALID_TOKEN: 'INVALID_TOKEN', 6 TOKEN_EXPIRED: 'TOKEN_EXPIRED', 7 FORBIDDEN: 'FORBIDDEN', 8 9 // Validation errors 10 VALIDATION_ERROR: 'VALIDATION_ERROR', 11 INVALID_FORMAT: 'INVALID_FORMAT', 12 MISSING_FIELD: 'MISSING_FIELD', 13 14 // Resource errors 15 NOT_FOUND: 'NOT_FOUND', 16 CONFLICT: 'CONFLICT', 17 ALREADY_EXISTS: 'ALREADY_EXISTS', 18 19 // Rate limiting 20 RATE_LIMITED: 'RATE_LIMITED', 21 22 // Server errors 23 INTERNAL_ERROR: 'INTERNAL_ERROR', 24 SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', 25 DATABASE_ERROR: 'DATABASE_ERROR', 26 EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR', 27} as const; 28 29type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];

Retry Headers#

1// Add retry information for recoverable errors 2function setRetryHeaders(res: Response, error: AppError): void { 3 if (error instanceof RateLimitError) { 4 res.setHeader('Retry-After', error.details?.retryAfter || 60); 5 res.setHeader('X-RateLimit-Remaining', 0); 6 } 7 8 if (error.statusCode === 503) { 9 res.setHeader('Retry-After', 30); 10 } 11} 12 13// Client can use these headers 14interface RetryConfig { 15 maxRetries: number; 16 retryableStatuses: number[]; 17 backoffFactor: number; 18} 19 20async function fetchWithRetry( 21 url: string, 22 options: RequestInit, 23 config: RetryConfig 24): Promise<Response> { 25 let lastError: Error | null = null; 26 27 for (let attempt = 0; attempt <= config.maxRetries; attempt++) { 28 try { 29 const response = await fetch(url, options); 30 31 if (!config.retryableStatuses.includes(response.status)) { 32 return response; 33 } 34 35 // Check Retry-After header 36 const retryAfter = response.headers.get('Retry-After'); 37 const delay = retryAfter 38 ? parseInt(retryAfter) * 1000 39 : Math.pow(config.backoffFactor, attempt) * 1000; 40 41 await sleep(delay); 42 } catch (error) { 43 lastError = error as Error; 44 } 45 } 46 47 throw lastError || new Error('Max retries exceeded'); 48}

Logging Errors#

1function logError(error: Error, context: Record<string, unknown>): void { 2 const isOperational = error instanceof AppError; 3 4 const logData = { 5 name: error.name, 6 message: error.message, 7 code: error instanceof AppError ? error.code : undefined, 8 statusCode: error instanceof AppError ? error.statusCode : undefined, 9 stack: error.stack, 10 isOperational, 11 ...context, 12 }; 13 14 if (isOperational) { 15 // Expected errors - info level 16 logger.info('Operational error', logData); 17 } else { 18 // Unexpected errors - error level with alert 19 logger.error('Unexpected error', logData); 20 21 // Alert for unexpected errors 22 alertService.notify({ 23 severity: 'high', 24 message: error.message, 25 context: logData, 26 }); 27 } 28}

Best Practices#

Response Format: ✓ Consistent error structure ✓ Machine-readable error codes ✓ Human-readable messages ✓ Include request ID Status Codes: ✓ Use appropriate HTTP status ✓ 4xx for client errors ✓ 5xx for server errors ✓ Don't use 200 for errors Security: ✓ Don't leak internal details ✓ Sanitize error messages ✓ Log full details server-side ✓ Use generic messages for 500s Recovery: ✓ Include retry information ✓ Suggest corrective actions ✓ Provide documentation links ✓ Handle timeouts gracefully

Conclusion#

Consistent error handling improves API usability and debugging. Use custom error classes for different error types, return appropriate status codes, and include enough information for clients to understand and handle errors. Always log full details server-side while returning safe messages to clients.

Share this article

Help spread the word about Bootspring