X Xerobit

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...

Mian Ali Khalid · · 5 min read
Use the tool
HTTP Status Codes
Full HTTP status code reference with explanations and when to use each.
Open HTTP Status Codes →

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

CodeNameUse when
400Bad RequestMalformed request, missing required field
401UnauthorizedNot authenticated (no/invalid token)
403ForbiddenAuthenticated but not allowed
404Not FoundResource doesn’t exist
405Method Not AllowedWrong HTTP verb (POST on read-only endpoint)
409ConflictState conflict (duplicate email, optimistic lock)
410GoneResource permanently deleted
422Unprocessable EntityValid format but semantic error (validation failure)
429Too Many RequestsRate limit exceeded

5xx — server errors

CodeNameUse when
500Internal Server ErrorUnhandled exception, bug
502Bad GatewayUpstream service failed
503Service UnavailableOverloaded or down for maintenance
504Gateway TimeoutUpstream 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: -5 or email: "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 posts

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.