REST API Error Handling: The Right Way

TL;DR

Use proper HTTP status codes. Return consistent error format with code, message, and details. Log errors server-side. Never expose stack traces to clients.

My API returned "Error: undefined" for everything. 500 status codes for validation errors. No error codes, just generic messages. Clients couldn't handle errors programmatically. Support tickets piled up.

I implemented proper error handling and support tickets dropped 60%. Clients could handle errors gracefully. My logs became useful for debugging. One weekend of work.

Error handling seems simple but most APIs get it wrong. Here's how to do it right, with complete implementations and real examples.

The Problem: Bad Error Responses

// BAD: Inconsistent error responses
// Validation error
{
  "error": "Email is required"
}

// Database error
{
  "message": "Database connection failed",
  "code": "DB_ERROR"
}

// Authentication error
"Unauthorized"

// Server error
"Internal Server Error"

Clients can't handle these consistently. Different shapes, different fields, unpredictable.

The Solution: Consistent Error Format

// GOOD: Every error has the same shape
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "message": "Email is required"
      }
    ]
  }
}

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Authentication required"
  }
}

{
  "error": {
    "code": "NOT_FOUND",
    "message": "User not found",
    "details": {
      "userId": "12345"
    }
  }
}

Consistent shape. Clients can handle programmatically.

HTTP Status Codes: The Right Ones

2xx Success

// 200 OK - Request succeeded
app.get('/api/users/:id', async (req, res) => {
    const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
    res.status(200).json(user);
});

// 201 Created - Resource created
app.post('/api/users', async (req, res) => {
    const user = await db.query('INSERT INTO users SET ?', [req.body]);
    res.status(201).json(user);
});

// 204 No Content - Succeeded, nothing to return
app.delete('/api/users/:id', async (req, res) => {
    await db.query('DELETE FROM users WHERE id = ?', [req.params.id]);
    res.status(204).send();
});

4xx Client Errors

// 400 Bad Request - Invalid input
app.post('/api/users', async (req, res) => {
    if (!req.body.email) {
        return res.status(400).json({
            error: {
                code: 'BAD_REQUEST',
                message: 'Email is required'
            }
        });
    }
});

// 401 Unauthorized - Authentication required
app.get('/api/profile', async (req, res) => {
    if (!req.user) {
        return res.status(401).json({
            error: {
                code: 'UNAUTHORIZED',
                message: 'Authentication required'
            }
        });
    }
});

// 403 Forbidden - Authenticated but not allowed
app.delete('/api/users/:id', async (req, res) => {
    if (req.user.role !== 'admin') {
        return res.status(403).json({
            error: {
                code: 'FORBIDDEN',
                message: 'Admin access required'
            }
        });
    }
});

// 404 Not Found - Resource doesn't exist
app.get('/api/users/:id', async (req, res) => {
    const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);

    if (!user) {
        return res.status(404).json({
            error: {
                code: 'NOT_FOUND',
                message: 'User not found'
            }
        });
    }

    res.json(user);
});

// 409 Conflict - Resource already exists
app.post('/api/users', async (req, res) => {
    const existing = await db.query('SELECT id FROM users WHERE email = ?', [req.body.email]);

    if (existing) {
        return res.status(409).json({
            error: {
                code: 'CONFLICT',
                message: 'Email already registered'
            }
        });
    }
});

// 422 Unprocessable Entity - Validation failed
app.post('/api/users', async (req, res) => {
    const errors = validateUser(req.body);

    if (errors.length > 0) {
        return res.status(422).json({
            error: {
                code: 'VALIDATION_ERROR',
                message: 'Validation failed',
                details: errors
            }
        });
    }
});

// 429 Too Many Requests - Rate limit exceeded
app.use(rateLimiter);

app.get('/api/posts', async (req, res) => {
    if (rateLimitExceeded) {
        return res.status(429).json({
            error: {
                code: 'RATE_LIMIT_EXCEEDED',
                message: 'Too many requests',
                retryAfter: 60
            }
        });
    }
});

5xx Server Errors

// 500 Internal Server Error - Something broke
app.get('/api/users', async (req, res) => {
    try {
        const users = await db.query('SELECT * FROM users');
        res.json(users);
    } catch (error) {
        console.error('Database error:', error);

        res.status(500).json({
            error: {
                code: 'INTERNAL_ERROR',
                message: 'An unexpected error occurred'
            }
        });
    }
});

// 503 Service Unavailable - Temporarily down
app.get('/api/health', async (req, res) => {
    const isHealthy = await checkDatabaseConnection();

    if (!isHealthy) {
        return res.status(503).json({
            error: {
                code: 'SERVICE_UNAVAILABLE',
                message: 'Service is temporarily unavailable'
            }
        });
    }

    res.json({ status: 'healthy' });
});

Complete Error Handler Implementation

// Custom error classes
class APIError extends Error {
    constructor(statusCode, code, message, details = null) {
        super(message);
        this.statusCode = statusCode;
        this.code = code;
        this.details = details;
        this.isOperational = true;
        Error.captureStackTrace(this, this.constructor);
    }
}

class ValidationError extends APIError {
    constructor(message, details) {
        super(422, 'VALIDATION_ERROR', message, details);
    }
}

class NotFoundError extends APIError {
    constructor(message) {
        super(404, 'NOT_FOUND', message);
    }
}

class UnauthorizedError extends APIError {
    constructor(message = 'Authentication required') {
        super(401, 'UNAUTHORIZED', message);
    }
}

class ForbiddenError extends APIError {
    constructor(message = 'Access forbidden') {
        super(403, 'FORBIDDEN', message);
    }
}

class ConflictError extends APIError {
    constructor(message) {
        super(409, 'CONFLICT', message);
    }
}

// Error handler middleware
function errorHandler(err, req, res, next) {
    // Log error
    console.error('Error:', {
        code: err.code,
        message: err.message,
        stack: err.stack,
        url: req.url,
        method: req.method,
        ip: req.ip,
        userId: req.user?.id
    });

    // Operational errors (expected errors we throw)
    if (err.isOperational) {
        return res.status(err.statusCode).json({
            error: {
                code: err.code,
                message: err.message,
                details: err.details
            }
        });
    }

    // Programming errors (bugs)
    // Don't expose details to client
    res.status(500).json({
        error: {
            code: 'INTERNAL_ERROR',
            message: 'An unexpected error occurred'
        }
    });
}

app.use(errorHandler);

// Usage in routes
app.get('/api/users/:id', async (req, res, next) => {
    try {
        const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);

        if (!user) {
            throw new NotFoundError('User not found');
        }

        res.json(user);
    } catch (error) {
        next(error);
    }
});

app.post('/api/users', async (req, res, next) => {
    try {
        const errors = validateUser(req.body);

        if (errors.length > 0) {
            throw new ValidationError('Validation failed', errors);
        }

        const existing = await db.query('SELECT id FROM users WHERE email = ?', [req.body.email]);

        if (existing) {
            throw new ConflictError('Email already registered');
        }

        const user = await db.query('INSERT INTO users SET ?', [req.body]);
        res.status(201).json(user);
    } catch (error) {
        next(error);
    }
});

Async Error Handling Wrapper

// Wrapper to avoid try-catch in every route
function asyncHandler(fn) {
    return (req, res, next) => {
        Promise.resolve(fn(req, res, next)).catch(next);
    };
}

// Usage - much cleaner
app.get('/api/users/:id', asyncHandler(async (req, res) => {
    const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);

    if (!user) {
        throw new NotFoundError('User not found');
    }

    res.json(user);
}));

app.post('/api/users', asyncHandler(async (req, res) => {
    const errors = validateUser(req.body);

    if (errors.length > 0) {
        throw new ValidationError('Validation failed', errors);
    }

    const user = await db.query('INSERT INTO users SET ?', [req.body]);
    res.status(201).json(user);
}));

Validation Error Format

// Validation with detailed errors
function validateUser(data) {
    const errors = [];

    if (!data.email) {
        errors.push({
            field: 'email',
            message: 'Email is required',
            code: 'REQUIRED'
        });
    } else if (!isValidEmail(data.email)) {
        errors.push({
            field: 'email',
            message: 'Email is invalid',
            code: 'INVALID_FORMAT'
        });
    }

    if (!data.password) {
        errors.push({
            field: 'password',
            message: 'Password is required',
            code: 'REQUIRED'
        });
    } else if (data.password.length < 8) {
        errors.push({
            field: 'password',
            message: 'Password must be at least 8 characters',
            code: 'TOO_SHORT',
            min: 8
        });
    }

    if (data.age && (data.age < 13 || data.age > 120)) {
        errors.push({
            field: 'age',
            message: 'Age must be between 13 and 120',
            code: 'OUT_OF_RANGE',
            min: 13,
            max: 120
        });
    }

    return errors;
}

// Response
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "message": "Email is required",
        "code": "REQUIRED"
      },
      {
        "field": "password",
        "message": "Password must be at least 8 characters",
        "code": "TOO_SHORT",
        "min": 8
      }
    ]
  }
}

Using Validation Libraries

// With Joi
const Joi = require('joi');

const userSchema = Joi.object({
    email: Joi.string().email().required(),
    password: Joi.string().min(8).required(),
    age: Joi.number().min(13).max(120)
});

app.post('/api/users', asyncHandler(async (req, res) => {
    const { error } = userSchema.validate(req.body, { abortEarly: false });

    if (error) {
        const details = error.details.map(err => ({
            field: err.path[0],
            message: err.message,
            code: err.type.toUpperCase()
        }));

        throw new ValidationError('Validation failed', details);
    }

    const user = await db.query('INSERT INTO users SET ?', [req.body]);
    res.status(201).json(user);
}));

// With express-validator
const { body, validationResult } = require('express-validator');

app.post('/api/users',
    body('email').isEmail().withMessage('Invalid email'),
    body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
    asyncHandler(async (req, res) => {
        const errors = validationResult(req);

        if (!errors.isEmpty()) {
            const details = errors.array().map(err => ({
                field: err.path,
                message: err.msg,
                code: 'VALIDATION_ERROR'
            }));

            throw new ValidationError('Validation failed', details);
        }

        const user = await db.query('INSERT INTO users SET ?', [req.body]);
        res.status(201).json(user);
    })
);

Database Error Handling

// Handle common database errors
function handleDatabaseError(error) {
    // Duplicate key error
    if (error.code === 'ER_DUP_ENTRY' || error.code === '23505') {
        const field = extractFieldFromDuplicateError(error);
        throw new ConflictError(`${field} already exists`);
    }

    // Foreign key constraint
    if (error.code === 'ER_NO_REFERENCED_ROW' || error.code === '23503') {
        throw new ValidationError('Referenced record does not exist');
    }

    // Connection error
    if (error.code === 'ECONNREFUSED') {
        throw new APIError(503, 'SERVICE_UNAVAILABLE', 'Database connection failed');
    }

    // Unknown database error
    throw error;
}

// Usage
app.post('/api/users', asyncHandler(async (req, res) => {
    try {
        const user = await db.query('INSERT INTO users SET ?', [req.body]);
        res.status(201).json(user);
    } catch (error) {
        handleDatabaseError(error);
    }
}));

External API Error Handling

// Wrap external API calls
async function callExternalAPI(url, options) {
    try {
        const response = await fetch(url, {
            ...options,
            timeout: 5000
        });

        if (!response.ok) {
            if (response.status === 404) {
                throw new NotFoundError('External resource not found');
            }

            if (response.status === 429) {
                throw new APIError(429, 'RATE_LIMIT_EXCEEDED', 'External API rate limit exceeded');
            }

            throw new APIError(502, 'BAD_GATEWAY', 'External API error');
        }

        return await response.json();
    } catch (error) {
        if (error.type === 'request-timeout') {
            throw new APIError(504, 'GATEWAY_TIMEOUT', 'External API timeout');
        }

        if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
            throw new APIError(503, 'SERVICE_UNAVAILABLE', 'External API unavailable');
        }

        throw error;
    }
}

// Usage
app.get('/api/weather/:city', asyncHandler(async (req, res) => {
    const weather = await callExternalAPI(
        `https://api.weather.com/v1/forecast?city=${req.params.city}`,
        { headers: { 'API-Key': process.env.WEATHER_API_KEY } }
    );

    res.json(weather);
}));

Error Response Examples

Validation Error

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "message": "Email is invalid",
        "code": "INVALID_FORMAT"
      },
      {
        "field": "password",
        "message": "Password must be at least 8 characters",
        "code": "TOO_SHORT",
        "min": 8
      }
    ]
  }
}

Authentication Error

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid credentials"
  }
}

Not Found Error

{
  "error": {
    "code": "NOT_FOUND",
    "message": "User not found",
    "details": {
      "userId": "12345"
    }
  }
}

Rate Limit Error

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests",
    "retryAfter": 60
  }
}

Server Error

{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "An unexpected error occurred",
    "requestId": "abc-123-def"
  }
}

Request ID for Debugging

const { v4: uuidv4 } = require('uuid');

// Add request ID to all requests
app.use((req, res, next) => {
    req.id = uuidv4();
    res.setHeader('X-Request-ID', req.id);
    next();
});

// Include in error response
function errorHandler(err, req, res, next) {
    console.error('Error:', {
        requestId: req.id,
        error: err.message,
        stack: err.stack
    });

    res.status(err.statusCode || 500).json({
        error: {
            code: err.code || 'INTERNAL_ERROR',
            message: err.message,
            requestId: req.id
        }
    });
}

// Client can report requestId for support
// "I got an error, request ID: abc-123-def"

Logging Errors

const winston = require('winston');

const logger = winston.createLogger({
    level: 'info',
    format: winston.format.json(),
    transports: [
        new winston.transports.File({ filename: 'error.log', level: 'error' }),
        new winston.transports.File({ filename: 'combined.log' })
    ]
});

function errorHandler(err, req, res, next) {
    // Log with context
    logger.error('API Error', {
        requestId: req.id,
        code: err.code,
        message: err.message,
        stack: err.stack,
        url: req.url,
        method: req.method,
        userId: req.user?.id,
        ip: req.ip,
        userAgent: req.headers['user-agent']
    });

    // Return error to client
    if (err.isOperational) {
        return res.status(err.statusCode).json({
            error: {
                code: err.code,
                message: err.message,
                details: err.details,
                requestId: req.id
            }
        });
    }

    // Programming error - don't expose details
    res.status(500).json({
        error: {
            code: 'INTERNAL_ERROR',
            message: 'An unexpected error occurred',
            requestId: req.id
        }
    });
}

Error Monitoring

// Sentry integration
const Sentry = require('@sentry/node');

Sentry.init({ dsn: process.env.SENTRY_DSN });

app.use(Sentry.Handlers.requestHandler());

function errorHandler(err, req, res, next) {
    // Send to Sentry
    if (!err.isOperational) {
        Sentry.captureException(err, {
            user: { id: req.user?.id },
            tags: {
                endpoint: req.url,
                method: req.method
            }
        });
    }

    // Return error
    res.status(err.statusCode || 500).json({
        error: {
            code: err.code || 'INTERNAL_ERROR',
            message: err.message,
            requestId: req.id
        }
    });
}

app.use(Sentry.Handlers.errorHandler());
app.use(errorHandler);

Client-Side Error Handling

// API client with proper error handling
class APIClient {
    async request(url, options = {}) {
        try {
            const response = await fetch(url, options);

            // Success
            if (response.ok) {
                return await response.json();
            }

            // Parse error
            const error = await response.json();

            // Handle specific errors
            switch (error.error.code) {
                case 'VALIDATION_ERROR':
                    throw new ValidationError(error.error.details);
                case 'UNAUTHORIZED':
                    // Redirect to login
                    window.location.href = '/login';
                    throw new Error('Unauthorized');
                case 'NOT_FOUND':
                    throw new NotFoundError(error.error.message);
                case 'RATE_LIMIT_EXCEEDED':
                    // Retry after delay
                    await new Promise(resolve =>
                        setTimeout(resolve, error.error.retryAfter * 1000)
                    );
                    return this.request(url, options);
                default:
                    throw new APIError(error.error.message);
            }
        } catch (error) {
            // Network error
            if (error.name === 'TypeError') {
                throw new Error('Network error. Please check your connection.');
            }

            throw error;
        }
    }
}

// Usage in React
async function createUser(data) {
    try {
        const user = await api.request('/api/users', {
            method: 'POST',
            body: JSON.stringify(data)
        });

        return { success: true, user };
    } catch (error) {
        if (error instanceof ValidationError) {
            // Show validation errors in form
            return { success: false, errors: error.details };
        }

        // Generic error
        return { success: false, message: error.message };
    }
}

Testing Error Responses

const request = require('supertest');

describe('POST /api/users', () => {
    it('returns 422 for validation errors', async () => {
        const response = await request(app)
            .post('/api/users')
            .send({ email: 'invalid' })
            .expect(422);

        expect(response.body.error.code).toBe('VALIDATION_ERROR');
        expect(response.body.error.details).toHaveLength(2);
    });

    it('returns 409 for duplicate email', async () => {
        await request(app)
            .post('/api/users')
            .send({ email: 'john@example.com', password: 'password123' })
            .expect(201);

        const response = await request(app)
            .post('/api/users')
            .send({ email: 'john@example.com', password: 'password123' })
            .expect(409);

        expect(response.body.error.code).toBe('CONFLICT');
    });

    it('returns 404 for non-existent user', async () => {
        const response = await request(app)
            .get('/api/users/99999')
            .expect(404);

        expect(response.body.error.code).toBe('NOT_FOUND');
    });

    it('includes request ID in error response', async () => {
        const response = await request(app)
            .get('/api/users/99999')
            .expect(404);

        expect(response.body.error.requestId).toBeDefined();
    });
});

Documentation

Document all possible errors:

## POST /api/users

Create a new user.

### Request Body
```json
{
  "email": "user@example.com",
  "password": "password123"
}

Success Response (201 Created)

{
  "id": 1,
  "email": "user@example.com"
}

Error Responses

422 Unprocessable Entity

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "message": "Email is invalid",
        "code": "INVALID_FORMAT"
      }
    ]
  }
}

409 Conflict

{
  "error": {
    "code": "CONFLICT",
    "message": "Email already registered"
  }
}

500 Internal Server Error

{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "An unexpected error occurred",
    "requestId": "abc-123-def"
  }
}

## Common Mistakes

**Mistake 1: Using 200 for errors**

```javascript
// BAD
app.post('/api/users', async (req, res) => {
    const errors = validateUser(req.body);

    if (errors.length > 0) {
        return res.status(200).json({
            success: false,
            errors
        });
    }
});

// GOOD
app.post('/api/users', async (req, res) => {
    const errors = validateUser(req.body);

    if (errors.length > 0) {
        return res.status(422).json({
            error: {
                code: 'VALIDATION_ERROR',
                message: 'Validation failed',
                details: errors
            }
        });
    }
});

Mistake 2: Exposing stack traces

// BAD
app.use((err, req, res, next) => {
    res.status(500).json({
        error: err.message,
        stack: err.stack // Never do this!
    });
});

// GOOD
app.use((err, req, res, next) => {
    console.error(err.stack); // Log server-side

    res.status(500).json({
        error: {
            code: 'INTERNAL_ERROR',
            message: 'An unexpected error occurred'
        }
    });
});

Mistake 3: Inconsistent error format

// BAD - different shapes
res.json({ error: 'Not found' });
res.json({ message: 'Invalid input', errors: [] });
res.json({ success: false, reason: 'Unauthorized' });

// GOOD - consistent shape
res.json({
    error: {
        code: 'NOT_FOUND',
        message: 'Not found'
    }
});

res.json({
    error: {
        code: 'VALIDATION_ERROR',
        message: 'Invalid input',
        details: []
    }
});

The Bottom Line

Good error handling makes APIs usable:

Use proper HTTP status codes. 4xx for client errors, 5xx for server errors.

Return consistent error format. Same shape every time with code, message, details.

Never expose stack traces or internal details to clients. Log them server-side.

Include request IDs for debugging. Makes support much easier.

Document all errors. Clients need to know what to expect.

Handle errors at the edge. Catch and format at middleware level, not in every route.

My API went from "Error: undefined" to proper error handling in one weekend. Support tickets dropped 60% because clients could handle errors programmatically instead of breaking.

Invest in error handling early. Your future self and your users will thank you.