REST API Error Handling — HTTP Status Codes and Error Response Design
Design consistent REST API error responses using the right HTTP status codes. Learn the difference between 400 Bad Request, 404 Not Found, 409 Conflict, and 422 Unprocessable...
Use the tool
HTTP Status Codes
Full HTTP status code reference with explanations and when to use each.
Consistent error responses are as important as successful ones. Clients need predictable error shapes — a different structure per endpoint forces every caller to write bespoke error handling.
Use the HTTP Status Codes Reference to look up any status code meaning.
Status code selection for errors
4xx — client errors
| Code | Name | Use when |
|---|---|---|
| 400 | Bad Request | Malformed request, missing required field |
| 401 | Unauthorized | Not authenticated (no/invalid token) |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource doesn’t exist |
| 405 | Method Not Allowed | Wrong HTTP verb (POST on read-only endpoint) |
| 409 | Conflict | State conflict (duplicate email, optimistic lock) |
| 410 | Gone | Resource permanently deleted |
| 422 | Unprocessable Entity | Valid format but semantic error (validation failure) |
| 429 | Too Many Requests | Rate limit exceeded |
5xx — server errors
| Code | Name | Use when |
|---|---|---|
| 500 | Internal Server Error | Unhandled exception, bug |
| 502 | Bad Gateway | Upstream service failed |
| 503 | Service Unavailable | Overloaded or down for maintenance |
| 504 | Gateway Timeout | Upstream didn’t respond in time |
400 vs 422 — which to use?
- 400: The request is syntactically malformed — invalid JSON, missing
Content-Type, field that can’t be parsed. - 422: The request is well-formed but semantically invalid — the JSON parsed fine, but
age: -5oremail: "not-an-email".
POST /users
Content-Type: text/plain ← 400 (wrong content type)
POST /users
{"email": "not-an-email"} ← 422 (valid JSON, invalid email)
POST /users
{invalid json ← 400 (malformed JSON)
Error response shape
A consistent structure every endpoint uses:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Must be a valid email address"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Must be between 0 and 150"
}
],
"requestId": "req_01HV2T..."
}
}
The code is a machine-readable string — clients can switch on it without parsing the human message.
Express.js error handling
// Error class with HTTP status:
class ApiError extends Error {
constructor(statusCode, code, message, details = []) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
static badRequest(message, details) {
return new ApiError(400, 'BAD_REQUEST', message, details);
}
static notFound(resource) {
return new ApiError(404, 'NOT_FOUND', `${resource} not found`);
}
static conflict(message) {
return new ApiError(409, 'CONFLICT', message);
}
static unprocessable(details) {
return new ApiError(422, 'VALIDATION_ERROR', 'Request validation failed', details);
}
static internal() {
return new ApiError(500, 'INTERNAL_ERROR', 'An unexpected error occurred');
}
}
// Global error handler middleware (must have 4 params):
app.use((err, req, res, next) => {
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
requestId: req.id,
}
});
}
// Unknown errors — don't leak stack traces
console.error(err);
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId: req.id,
}
});
});
// Usage in routes:
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.users.findById(req.params.id);
if (!user) throw ApiError.notFound('User');
res.json(user);
} catch (err) {
next(err);
}
});
Validation errors with Zod
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(150),
name: z.string().min(1).max(100),
});
app.post('/users', async (req, res, next) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
const details = result.error.issues.map(issue => ({
field: issue.path.join('.'),
code: issue.code.toUpperCase(),
message: issue.message,
}));
return next(ApiError.unprocessable(details));
}
// result.data is type-safe here
const user = await db.users.create(result.data);
res.status(201).json(user);
});
Rate limit errors
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true, // Return RateLimit-* headers
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
}
});
},
});
Client-side error handling
async function apiRequest(url, options = {}) {
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const error = new Error(body.error?.message || `HTTP ${res.status}`);
error.status = res.status;
error.code = body.error?.code;
error.details = body.error?.details;
throw error;
}
return res.json();
}
// Usage:
try {
const user = await apiRequest('/api/users', {
method: 'POST',
body: JSON.stringify({ email: 'bad' }),
});
} catch (err) {
if (err.status === 422) {
showValidationErrors(err.details);
} else if (err.status === 401) {
redirectToLogin();
} else {
showGenericError();
}
}
Related tools
- HTTP Status Codes Reference — complete status code lookup
- HTTP Redirect Codes — 301, 302, 307, 308 guide
- JSON API Response Format — response body conventions
Related posts
- When to Use 422 vs 400 (and Other HTTP Status Code Debates) — 400 means the request is malformed. 422 means it's valid but semantically wrong.…
- HTTP 301 vs 302 Redirect — Permanent vs Temporary Redirects Explained — Understand when to use 301 (permanent) vs 302 (temporary) redirects, how they af…
- HTTP 404 Not Found — What It Means and How to Fix It — HTTP 404 means the server understood the request but can't find the resource. It…
- HTTP Redirect Status Codes — 301, 302, 307, 308 Explained — HTTP 3xx redirect codes tell clients to look elsewhere for a resource. Here's wh…
- HTTP Status Codes Reference — Complete Guide to All Response Codes — HTTP status codes tell clients what happened with their request. Here's a comple…
Related tool
HTTP Status Codes
Full HTTP status code reference with explanations and when to use each.
Written by Mian Ali Khalid. Part of the Dev Productivity pillar.