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.