API Error Handling: Consistent Error Responses
Every API eventually becomes a minefield of inconsistent error responses. One endpoint returns `{ error: 'Not found' }`, another returns `{ message: 'User does not exist', code: 404 }`, and a third...
Key Insights
- Inconsistent error responses force frontend developers to write defensive parsing logic for every endpoint, leading to brittle code and poor user experiences when error formats change unexpectedly.
- A standardized error schema with status codes, error types, messages, and structured validation details enables centralized client-side error handling and dramatically reduces debugging time.
- Express middleware combined with custom error classes provides a clean abstraction that transforms all errors—from validation failures to database crashes—into a predictable JSON format.
The Cost of Inconsistent Error Handling
Every API eventually becomes a minefield of inconsistent error responses. One endpoint returns { error: "Not found" }, another returns { message: "User does not exist", code: 404 }, and a third returns { success: false, errors: ["Invalid request"] }. Frontend developers end up writing defensive code like this:
// The nightmare of inconsistent error handling
try {
const response = await api.updateUser(data);
} catch (err) {
const message =
err.response?.data?.error ||
err.response?.data?.message ||
err.response?.data?.errors?.[0] ||
err.message ||
'Something went wrong';
showError(message);
}
This isn’t just annoying—it’s expensive. Debugging becomes harder, error monitoring tools can’t aggregate similar errors, and users see generic messages because developers can’t reliably extract meaningful information. Let’s fix this.
Designing a Standard Error Response Schema
A robust error response schema needs to balance completeness with simplicity. Here’s what every error response should include:
interface ApiErrorResponse {
status: number; // HTTP status code
errorCode: string; // Machine-readable error type
message: string; // Human-readable message
timestamp: string; // ISO 8601 timestamp
requestId: string; // For tracing/debugging
path: string; // Request path
details?: ErrorDetail[]; // Optional validation errors
}
interface ErrorDetail {
field: string;
message: string;
code?: string;
}
A concrete example of this schema in action:
{
"status": 422,
"errorCode": "VALIDATION_ERROR",
"message": "Request validation failed",
"timestamp": "2024-01-15T10:30:00.000Z",
"requestId": "req_abc123xyz",
"path": "/api/users",
"details": [
{
"field": "email",
"message": "Must be a valid email address",
"code": "invalid_format"
},
{
"field": "age",
"message": "Must be at least 18",
"code": "min_value"
}
]
}
The errorCode field is crucial—it lets frontend code handle specific errors programmatically without parsing strings. The details array handles complex validation scenarios where multiple fields have errors.
Implementing Error Response Middleware
Start by creating custom error classes that extend the base Error class:
class ApiError extends Error {
constructor(status, errorCode, message, details = null) {
super(message);
this.status = status;
this.errorCode = errorCode;
this.details = details;
this.isOperational = true; // Distinguish from programming errors
}
}
class ValidationError extends ApiError {
constructor(details, message = 'Request validation failed') {
super(422, 'VALIDATION_ERROR', message, details);
}
}
class AuthenticationError extends ApiError {
constructor(message = 'Authentication required') {
super(401, 'AUTHENTICATION_ERROR', message);
}
}
class NotFoundError extends ApiError {
constructor(resource = 'Resource') {
super(404, 'NOT_FOUND', `${resource} not found`);
}
}
class ConflictError extends ApiError {
constructor(message = 'Resource already exists') {
super(409, 'CONFLICT', message);
}
}
Now create the global error handling middleware:
const errorHandler = (err, req, res, next) => {
// Generate unique request ID if not already present
const requestId = req.id || generateRequestId();
// Log the error with context
logger.error({
requestId,
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
// Handle operational errors (expected errors)
if (err.isOperational) {
return res.status(err.status).json({
status: err.status,
errorCode: err.errorCode,
message: err.message,
timestamp: new Date().toISOString(),
requestId,
path: req.path,
details: err.details,
});
}
// Handle unexpected errors (programming errors)
// Never expose internal error details to clients
res.status(500).json({
status: 500,
errorCode: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
timestamp: new Date().toISOString(),
requestId,
path: req.path,
});
};
For async route handlers, create a wrapper to catch rejected promises:
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Usage
app.post('/api/users', asyncHandler(async (req, res) => {
const user = await userService.create(req.body);
res.status(201).json(user);
}));
HTTP Status Codes and Error Types Mapping
Map common scenarios to appropriate status codes and error types:
const ErrorTypes = {
// Client errors (4xx)
VALIDATION_ERROR: { status: 422, code: 'VALIDATION_ERROR' },
AUTHENTICATION_ERROR: { status: 401, code: 'AUTHENTICATION_ERROR' },
AUTHORIZATION_ERROR: { status: 403, code: 'AUTHORIZATION_ERROR' },
NOT_FOUND: { status: 404, code: 'NOT_FOUND' },
CONFLICT: { status: 409, code: 'CONFLICT' },
RATE_LIMIT_EXCEEDED: { status: 429, code: 'RATE_LIMIT_EXCEEDED' },
// Server errors (5xx)
INTERNAL_SERVER_ERROR: { status: 500, code: 'INTERNAL_SERVER_ERROR' },
SERVICE_UNAVAILABLE: { status: 503, code: 'SERVICE_UNAVAILABLE' },
DATABASE_ERROR: { status: 500, code: 'DATABASE_ERROR' },
};
Use 422 (Unprocessable Entity) for validation errors, not 400 (Bad Request). Reserve 400 for malformed requests that can’t be parsed. Use 409 (Conflict) for business logic violations like duplicate emails.
Validation Errors and Field-Level Details
Transform validation library errors into your standard format. Here’s how to handle Zod validation:
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email(),
age: z.number().min(18),
username: z.string().min(3).max(20),
});
const validateRequest = (schema) => (req, res, next) => {
try {
schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
const details = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
}));
throw new ValidationError(details);
}
next(error);
}
};
// Usage
app.post('/api/users',
validateRequest(userSchema),
asyncHandler(createUser)
);
Client-Side Error Handling
Create an axios interceptor to handle errors consistently:
import axios, { AxiosError } from 'axios';
interface ApiErrorResponse {
status: number;
errorCode: string;
message: string;
timestamp: string;
requestId: string;
path: string;
details?: Array<{ field: string; message: string }>;
}
axios.interceptors.response.use(
response => response,
(error: AxiosError<ApiErrorResponse>) => {
if (error.response?.data) {
const apiError = error.response.data;
// Handle specific error types
switch (apiError.errorCode) {
case 'AUTHENTICATION_ERROR':
// Redirect to login
window.location.href = '/login';
break;
case 'VALIDATION_ERROR':
// Return validation details for form handling
return Promise.reject(apiError);
default:
// Show generic error notification
showNotification(apiError.message, 'error');
}
}
return Promise.reject(error);
}
);
Create a reusable error display utility:
const displayFieldErrors = (
error: ApiErrorResponse,
setFieldError: (field: string, message: string) => void
) => {
if (error.errorCode === 'VALIDATION_ERROR' && error.details) {
error.details.forEach(detail => {
setFieldError(detail.field, detail.message);
});
}
};
// Usage with React Hook Form
try {
await api.createUser(data);
} catch (error) {
if (error.errorCode === 'VALIDATION_ERROR') {
displayFieldErrors(error, setError);
}
}
Best Practices and Testing
Never expose sensitive information in error messages. Use generic messages for authentication failures:
// Bad: Reveals user existence
throw new AuthenticationError('User with email user@example.com not found');
// Good: Generic message
throw new AuthenticationError('Invalid credentials');
Write comprehensive tests for your error handling:
import request from 'supertest';
import app from './app';
describe('Error Handling Middleware', () => {
it('returns standardized validation error', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid', age: 15 });
expect(response.status).toBe(422);
expect(response.body).toMatchObject({
status: 422,
errorCode: 'VALIDATION_ERROR',
message: expect.any(String),
timestamp: expect.any(String),
requestId: expect.any(String),
path: '/api/users',
details: expect.arrayContaining([
expect.objectContaining({
field: 'email',
message: expect.any(String),
}),
]),
});
});
it('returns 404 for non-existent resources', async () => {
const response = await request(app)
.get('/api/users/nonexistent');
expect(response.status).toBe(404);
expect(response.body.errorCode).toBe('NOT_FOUND');
});
it('hides internal errors from clients', async () => {
// Simulate database failure
jest.spyOn(db, 'query').mockRejectedValue(new Error('Connection failed'));
const response = await request(app)
.get('/api/users');
expect(response.status).toBe(500);
expect(response.body.message).not.toContain('Connection failed');
expect(response.body.errorCode).toBe('INTERNAL_SERVER_ERROR');
});
});
Standardized error handling isn’t glamorous, but it’s the foundation of maintainable APIs. Implement it early, enforce it consistently, and your future self—and every frontend developer who consumes your API—will thank you.