Error Handling Strategies

Module 22: Web Frameworks I (JavaScript) - Tuesday: Express.js Advanced

Understanding Express Error Handling

Error handling is a critical aspect of building robust Express applications. Proper error handling ensures that your application can gracefully handle failures, provide meaningful feedback to users, and maintain system stability.

flowchart TD A[Client Request] --> B{Regular Middleware} B -->|next()| C{Route Handler} B -->|next(error)| E C -->|try/catch| D[Response] C -->|uncaught error| E{Error Handling Middleware} E --> F[Error Response] style B fill:#a1c2ff,stroke:#333,stroke-width:2px style C fill:#f9d71c,stroke:#333,stroke-width:2px style E fill:#ff9e9e,stroke:#333,stroke-width:2px

The Safety Net Analogy

Think of Express error handling like a series of safety nets in a circus:

  • Performers (your route handlers and middleware) are doing complex acrobatics
  • Local Safety Nets (try/catch blocks) are positioned to catch falls in specific areas
  • Main Safety Net (error-handling middleware) spans the entire performance area to catch any falls missed by local nets
  • Emergency Response Team (error logging and monitoring) assesses the situation when someone falls into a net
  • Audience Communication (error responses) informs spectators about what's happening when performances don't go as planned

Just as a circus prioritizes safety with multiple layers of protection, a well-designed Express application implements multiple layers of error handling to ensure reliability.

Types of Errors in Express Applications

Understanding the different types of errors you might encounter helps you handle them appropriately:

Operational Errors

These are runtime errors that occur during normal operation and can be anticipated:

Programming Errors

These are bugs in your code that should be fixed rather than handled at runtime:

System Errors

These are errors related to system resources or environment:

Common HTTP Status Codes for Errors

Status Code Name When to Use Example Scenario
400 Bad Request Client sent invalid data Missing required fields, invalid format
401 Unauthorized Authentication required Missing or invalid authentication token
403 Forbidden Client not allowed to access resource User doesn't have required permissions
404 Not Found Resource doesn't exist Requested user, product, etc. not found
409 Conflict Request conflicts with current state Trying to create a user with an email that already exists
422 Unprocessable Entity Request was well-formed but semantically invalid All required fields present but with invalid values
429 Too Many Requests Rate limit exceeded User made too many requests in a short time
500 Internal Server Error Unexpected error on server Unhandled exceptions, database errors
503 Service Unavailable Server temporarily unable to handle request Server overloaded or under maintenance

Choosing the right status code helps clients understand what went wrong and how to address it, improving the overall API experience.

Error Handling Middleware

Express provides a special type of middleware for handling errors, which has four parameters instead of three:

Basic Error Handling Middleware


// Basic global error handler
app.use((err, req, res, next) => {
  // Log error
  console.error(err.stack);
  
  // Send error response
  res.status(500).json({
    error: {
      message: 'Something went wrong on the server'
    }
  });
});
                

This simple error handler catches any errors passed to next() and returns a generic 500 response. However, this approach doesn't distinguish between different types of errors.

Enhanced Error Handling Middleware


// More sophisticated error handler
app.use((err, req, res, next) => {
  // Set default status code and message
  const statusCode = err.statusCode || 500;
  let errorMessage = err.message || 'Something went wrong on the server';
  
  // Log error details (but be careful not to log sensitive info)
  console.error(`[ERROR] ${statusCode} - ${errorMessage}`);
  console.error(err.stack);
  
  // Hide actual error message in production to prevent sensitive info leakage
  if (process.env.NODE_ENV === 'production' && statusCode === 500) {
    errorMessage = 'An unexpected error occurred';
  }
  
  // Prepare response
  const errorResponse = {
    success: false,
    error: {
      message: errorMessage
    }
  };
  
  // Add error details for non-500 errors or in development
  if (statusCode !== 500 || process.env.NODE_ENV !== 'production') {
    if (err.details) {
      errorResponse.error.details = err.details;
    }
    
    if (err.code) {
      errorResponse.error.code = err.code;
    }
  }
  
  // Send error response
  res.status(statusCode).json(errorResponse);
});
                

This enhanced error handler provides more flexibility by using properties from the error object to determine the status code and response format, while also implementing security best practices for production environments.

Multiple Error Handlers for Different Types


// 404 handler - should be placed after all routes
app.use((req, res, next) => {
  const error = new Error('Not Found');
  error.statusCode = 404;
  next(error);
});

// Validation error handler
app.use((err, req, res, next) => {
  if (err.name === 'ValidationError') {
    return res.status(400).json({
      success: false,
      error: {
        message: 'Validation Failed',
        details: err.details || err.errors
      }
    });
  }
  next(err);
});

// Database error handler
app.use((err, req, res, next) => {
  if (err.name === 'MongoError' || err.name === 'SequelizeError') {
    console.error('Database Error:', err);
    
    // Check for duplicate key error
    if (err.code === 11000 || err.name === 'SequelizeUniqueConstraintError') {
      return res.status(409).json({
        success: false,
        error: {
          message: 'A resource with that identifier already exists'
        }
      });
    }
    
    return res.status(500).json({
      success: false,
      error: {
        message: 'Database operation failed'
      }
    });
  }
  next(err);
});

// Authentication error handler
app.use((err, req, res, next) => {
  if (err.name === 'UnauthorizedError' || err.name === 'TokenExpiredError') {
    return res.status(401).json({
      success: false,
      error: {
        message: 'Authentication failed',
        details: err.message
      }
    });
  }
  next(err);
});

// Default/fallback error handler - should be the last one
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const errorMessage = (statusCode === 500 && process.env.NODE_ENV === 'production')
    ? 'An unexpected error occurred'
    : err.message || 'Something went wrong';
  
  console.error(`[ERROR] ${req.method} ${req.path}:`, err);
  
  res.status(statusCode).json({
    success: false,
    error: {
      message: errorMessage
    }
  });
});
                

This example demonstrates using multiple error handlers in sequence to handle different types of errors. The handlers are executed in order, and each one either handles a specific type of error or passes it to the next handler.

Error Handler Stack in Production Applications

In real-world applications, error handling is often layered to address various concerns:

  1. Operational Error Handlers: Handle expected errors (404, validation, etc.)
  2. Security Error Handlers: Handle authentication/authorization errors
  3. External Service Error Handlers: Handle database, API, and other service errors
  4. Logging and Monitoring Handlers: Record error details for analysis
  5. Client Response Formatter: Ensure consistent error response format
  6. Fallback Error Handler: Catch any unhandled errors

Companies like Netflix, Airbnb, and Shopify implement sophisticated error handling systems that not only manage errors but also trigger alerts, collect metrics, and even automatically recover from certain failures.

Creating Custom Error Classes

Custom error classes help standardize error handling and make it easier to identify and process specific types of errors:

Base API Error Class


// errors/ApiError.js
class ApiError extends Error {
  constructor(message, statusCode, details = null) {
    super(message);
    
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.details = details;
    
    // Capture stack trace
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = ApiError;
                

This base API error class extends the built-in Error class with additional properties useful for API error handling.

Specific Error Types


// errors/BadRequestError.js
const ApiError = require('./ApiError');

class BadRequestError extends ApiError {
  constructor(message = 'Bad Request', details = null) {
    super(message, 400, details);
  }
}

module.exports = BadRequestError;

// errors/NotFoundError.js
const ApiError = require('./ApiError');

class NotFoundError extends ApiError {
  constructor(resource = 'Resource', details = null) {
    super(`${resource} not found`, 404, details);
  }
}

module.exports = NotFoundError;

// errors/UnauthorizedError.js
const ApiError = require('./ApiError');

class UnauthorizedError extends ApiError {
  constructor(message = 'Authentication required', details = null) {
    super(message, 401, details);
  }
}

module.exports = UnauthorizedError;

// errors/ForbiddenError.js
const ApiError = require('./ApiError');

class ForbiddenError extends ApiError {
  constructor(message = 'Access denied', details = null) {
    super(message, 403, details);
  }
}

module.exports = ForbiddenError;

// errors/ValidationError.js
const ApiError = require('./ApiError');

class ValidationError extends ApiError {
  constructor(details = null) {
    super('Validation failed', 422, details);
  }
}

module.exports = ValidationError;

// errors/index.js
module.exports = {
  ApiError: require('./ApiError'),
  BadRequestError: require('./BadRequestError'),
  NotFoundError: require('./NotFoundError'),
  UnauthorizedError: require('./UnauthorizedError'),
  ForbiddenError: require('./ForbiddenError'),
  ValidationError: require('./ValidationError')
};
                

This example shows how to create specific error classes for different HTTP error scenarios. Each class inherits from the base ApiError and sets appropriate default values for its type.

Using Custom Error Classes


// Import error classes
const { 
  NotFoundError, 
  ValidationError, 
  UnauthorizedError, 
  ForbiddenError 
} = require('./errors');

// In route handlers
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    
    if (!user) {
      // Throw specific error type
      throw new NotFoundError('User');
    }
    
    // Check permissions
    if (!req.user) {
      throw new UnauthorizedError();
    }
    
    if (req.user.role !== 'admin' && req.user.id !== user.id) {
      throw new ForbiddenError('You can only access your own profile');
    }
    
    res.json(user);
  } catch (err) {
    next(err); // Pass to error handling middleware
  }
});

// In validation middleware
const validateUser = (req, res, next) => {
  const { name, email, password } = req.body;
  const errors = [];
  
  if (!name) errors.push({ field: 'name', message: 'Name is required' });
  if (!email) errors.push({ field: 'email', message: 'Email is required' });
  if (!password || password.length < 8) {
    errors.push({ field: 'password', message: 'Password must be at least 8 characters' });
  }
  
  if (errors.length > 0) {
    // Use validation error with details
    next(new ValidationError(errors));
    return;
  }
  
  next();
};

// In global error handler
app.use((err, req, res, next) => {
  // Already formatted ApiError errors
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      success: false,
      error: {
        message: err.message,
        ...(err.details && { details: err.details })
      }
    });
  }
  
  // Handle other errors
  console.error(err);
  
  res.status(500).json({
    success: false,
    error: {
      message: process.env.NODE_ENV === 'production'
        ? 'An unexpected error occurred'
        : err.message
    }
  });
});
                

This example demonstrates how to use custom error classes in route handlers and middleware. The global error handler can then easily identify and process these errors appropriately.

Error Classification in Enterprise Applications

In large applications, error classification often goes beyond HTTP status codes to include:

  • Domain-specific Errors: ProductOutOfStockError, PaymentDeclinedError, etc.
  • Severity Levels: Critical, Warning, Info
  • Error Codes: Unique identifiers for specific error scenarios (e.g., AUTH001, DB003)
  • Recovery Hints: Suggestions on how to resolve the issue

This detailed classification helps with debugging, monitoring, and providing better user experiences in complex systems.

Asynchronous Error Handling

Handling errors in asynchronous code requires special consideration to ensure they are properly caught and processed:

Promise Error Handling


// Using promises with .catch()
app.get('/api/users/:id', (req, res, next) => {
  User.findById(req.params.id)
    .then(user => {
      if (!user) {
        throw new NotFoundError('User');
      }
      res.json(user);
    })
    .catch(err => next(err)); // Pass errors to Express error handler
});
                

This example shows how to handle errors when using Promise-based code with the .catch() method.

Async/Await Error Handling


// Using async/await with try/catch
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    
    if (!user) {
      throw new NotFoundError('User');
    }
    
    res.json(user);
  } catch (err) {
    next(err); // Pass errors to Express error handler
  }
});
                

This example demonstrates error handling with async/await using try/catch blocks.

Async Error Wrapper Utility


// utilities/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

module.exports = asyncHandler;

// Using the wrapper
const asyncHandler = require('../utilities/asyncHandler');

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    throw new NotFoundError('User');
  }
  
  res.json(user);
}));

// Multiple async handlers in a route
app.post('/api/orders', 
  asyncHandler(async (req, res, next) => {
    // Validate product availability
    const product = await Product.findById(req.body.productId);
    
    if (!product) {
      throw new NotFoundError('Product');
    }
    
    if (product.stock < req.body.quantity) {
      throw new BadRequestError('Not enough stock available');
    }
    
    // Attach product to request for next handler
    req.product = product;
    next();
  }),
  asyncHandler(async (req, res) => {
    // Create order with validated product
    const order = await Order.create({
      productId: req.product.id,
      quantity: req.body.quantity,
      userId: req.user.id,
      total: req.product.price * req.body.quantity
    });
    
    // Update product stock
    await Product.findByIdAndUpdate(req.product.id, {
      $inc: { stock: -req.body.quantity }
    });
    
    res.status(201).json(order);
  })
);
                

This example shows how to create and use an async handler utility that wraps async route handlers and automatically catches errors, reducing boilerplate code and ensuring consistent error handling.

Unhandled Promise Rejections

Even with careful error handling, some Promise rejections might be missed. It's important to set up global handlers for these cases:


// Global unhandled rejection handler (in your main application file)
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // In production, you might want to:
  // 1. Log to a monitoring service
  // 2. Gracefully restart the application
  // 3. Alert operations team
});

// Global uncaught exception handler
process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception:', error);
  // In production, similar to above, but uncaught exceptions are
  // more serious and usually indicate a need to restart
  
  // Attempt to close gracefully, but force exit after timeout
  server.close(() => {
    process.exit(1);
  });
  
  // If it takes too long to close, force exit
  setTimeout(() => {
    process.exit(1);
  }, 10000);
});
                

These global handlers act as last-resort error catchers, preventing your application from crashing silently when unexpected errors occur. However, it's best to handle errors properly at the source whenever possible.

Centralized Error Handling

As applications grow in complexity, centralizing error handling logic becomes important for consistency and maintainability:

Error Handler Module


// errorHandler.js
const { ApiError } = require('./errors');
const logger = require('./logger');

// Controller for 404 errors
const notFoundHandler = (req, res, next) => {
  const error = new ApiError('Not Found - ' + req.originalUrl, 404);
  next(error);
};

// Main error handler
const errorHandler = (err, req, res, next) => {
  // If the error doesn't have a statusCode, assume 500
  const statusCode = err.statusCode || 500;
  
  // Get a reference to the original error if it's wrapped
  const originalError = err.originalError || err;
  
  // Create a structured error object for logging
  const errorObj = {
    message: err.message,
    stack: err.stack,
    statusCode,
    path: req.path,
    method: req.method,
    timestamp: new Date().toISOString()
  };
  
  // Add request ID if available (helpful for tracking errors across logs)
  if (req.id) {
    errorObj.requestId = req.id;
  }
  
  // Add user info if available (helps with debugging user-specific issues)
  if (req.user) {
    errorObj.user = {
      id: req.user.id,
      role: req.user.role
    };
  }
  
  // Log the error with appropriate level based on status code
  if (statusCode >= 500) {
    // Server errors are more critical
    logger.error('Server Error:', errorObj);
  } else if (statusCode >= 400) {
    // Client errors are less severe
    logger.warn('Client Error:', errorObj);
  }
  
  // Prepare response
  const errorResponse = {
    success: false,
    error: {
      message: statusCode === 500 && process.env.NODE_ENV === 'production'
        ? 'An unexpected error occurred'
        : err.message
    }
  };
  
  // Include error details if provided and not a server error in production
  if (err.details && (statusCode < 500 || process.env.NODE_ENV !== 'production')) {
    errorResponse.error.details = err.details;
  }
  
  // Include error code if available
  if (err.code) {
    errorResponse.error.code = err.code;
  }
  
  // In development mode, include stack trace for debugging
  if (process.env.NODE_ENV === 'development') {
    errorResponse.stack = err.stack;
  }
  
  // Send the response
  res.status(statusCode).json(errorResponse);
};

module.exports = {
  notFoundHandler,
  errorHandler
};
                

This example shows a centralized error handling module that includes both a 404 handler and a main error handler with consistent logging and response formatting.

Error Controller


// controllers/errorController.js
const { ApiError } = require('../errors');
const logger = require('../utils/logger');

// Map specific error types to handlers
const errorHandlers = {
  // Database errors
  MongoError: (err) => {
    // Handle specific MongoDB errors
    if (err.code === 11000) {
      // Duplicate key error
      return new ApiError(
        'A record with this value already exists', 
        409, 
        { field: Object.keys(err.keyValue)[0] }
      );
    }
    
    // Default database error
    return new ApiError('Database operation failed', 500);
  },
  
  // Validation errors from Mongoose
  ValidationError: (err) => {
    const details = Object.keys(err.errors).map(field => ({
      field,
      message: err.errors[field].message
    }));
    
    return new ApiError('Validation failed', 422, details);
  },
  
  // Validation errors from Joi or express-validator
  JoiValidationError: (err) => {
    const details = err.details.map(detail => ({
      field: detail.path.join('.'),
      message: detail.message
    }));
    
    return new ApiError('Validation failed', 422, details);
  },
  
  // Authentication errors
  JsonWebTokenError: () => new ApiError('Invalid token', 401),
  TokenExpiredError: () => new ApiError('Token has expired', 401),
  
  // Default handler for unknown error types
  default: (err) => {
    logger.error('Unhandled error type:', err);
    return new ApiError(
      'An unexpected error occurred', 
      500
    );
  }
};

// Process errors and convert to standardized ApiError format
const processError = (err) => {
  // If it's already an ApiError, just return it
  if (err instanceof ApiError) {
    return err;
  }
  
  // Find a handler for this error type
  const handler = errorHandlers[err.name] || errorHandlers.default;
  
  // Process the error
  const processedError = handler(err);
  
  // Store the original error for reference
  processedError.originalError = err;
  
  return processedError;
};

// Main error handler middleware
const handleError = (err, req, res, next) => {
  // Convert error to standardized format
  const processedError = processError(err);
  
  // Log error
  if (processedError.statusCode >= 500) {
    logger.error({
      message: processedError.message,
      originalError: processedError.originalError?.message,
      stack: processedError.stack,
      requestId: req.id,
      path: req.path
    });
  }
  
  // Send response
  const response = {
    success: false,
    error: {
      message: processedError.message
    }
  };
  
  // Include details if available
  if (processedError.details) {
    response.error.details = processedError.details;
  }
  
  // In development, include stack
  if (process.env.NODE_ENV === 'development') {
    response.stack = processedError.stack;
  }
  
  res.status(processedError.statusCode).json(response);
};

module.exports = {
  processError,
  handleError
};
                

This example demonstrates a more sophisticated error controller that maps different error types to specific handlers, ensuring consistent error processing across the application.

Error Handling in Microservices

In microservice architectures, error handling becomes even more critical and complex:

  • Error Propagation: Errors need to be properly propagated between services
  • Distributed Tracing: Including trace IDs in errors helps track issues across services
  • Circuit Breaking: Detecting service failures and preventing cascading failures
  • Fallback Mechanisms: Providing graceful degradation when services fail
graph TD A[Client] --> B[API Gateway] B --> C[Service A] B --> D[Service B] C --> E[Service C] B -.Error.-> A C -.Error.-> B D -.Error.-> B E -.Error.-> C F[Error Monitoring Service] -.-> B F -.-> C F -.-> D F -.-> E style B fill:#a1c2ff,stroke:#333,stroke-width:2px style F fill:#ff9e9e,stroke:#333,stroke-width:2px

Companies like Netflix, Uber, and Shopify implement sophisticated error handling systems in their microservice architectures, using tools like distributed tracing (e.g., Jaeger, Zipkin) and circuit breakers (e.g., Hystrix, Resilience4j) to manage failures gracefully.

Error Logging and Monitoring

Proper error logging and monitoring are essential for identifying, diagnosing, and resolving issues in production:

Structured Error Logging


// logger.js
const winston = require('winston');
const { format, transports } = winston;

// Create a custom format that handles errors specially
const errorStackFormat = format((info) => {
  if (info.error && info.error instanceof Error) {
    const errorDetails = {
      message: info.error.message,
      name: info.error.name,
      stack: info.error.stack
    };
    
    return {
      ...info,
      error: errorDetails
    };
  }
  
  return info;
});

// Create the logger
const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: format.combine(
    errorStackFormat(),
    format.timestamp(),
    format.json()
  ),
  defaultMeta: { service: 'api-service' },
  transports: [
    // Log to console in development
    new transports.Console({
      format: format.combine(
        format.colorize(),
        format.simple()
      )
    }),
    
    // Log errors to file in production
    ...(process.env.NODE_ENV === 'production' ? [
      new transports.File({ 
        filename: 'logs/error.log', 
        level: 'error',
        maxsize: 5242880, // 5MB
        maxFiles: 5
      }),
      new transports.File({ 
        filename: 'logs/combined.log',
        maxsize: 5242880, // 5MB
        maxFiles: 5
      })
    ] : [])
  ]
});

// Add streaming to external logging service in production
if (process.env.NODE_ENV === 'production') {
  // Example setup for logging to a service like Loggly or LogDNA
  // logger.add(new winston.transports.Http({...}));
}

// Logger interface
module.exports = {
  error: (message, meta = {}) => {
    logger.error(message, meta);
  },
  warn: (message, meta = {}) => {
    logger.warn(message, meta);
  },
  info: (message, meta = {}) => {
    logger.info(message, meta);
  },
  debug: (message, meta = {}) => {
    logger.debug(message, meta);
  },
  // Method specifically for logging errors
  logError: (err, req = {}) => {
    const meta = {
      error: err,
      request: {
        method: req.method,
        url: req.originalUrl,
        ip: req.ip,
        user: req.user?.id
      }
    };
    
    if (req.id) {
      meta.requestId = req.id;
    }
    
    logger.error(`${err.name}: ${err.message}`, meta);
  }
};
                

This example shows a structured logging setup using Winston, with specialized handling for errors including stack traces and request context.

Error Monitoring Integration


// Install Sentry: npm install @sentry/node @sentry/tracing

// app.js
const express = require('express');
const Sentry = require('@sentry/node');
const Tracing = require('@sentry/tracing');
const { errorHandler } = require('./errorHandler');

const app = express();

// Initialize Sentry
// (This should be the first middleware)
Sentry.init({
  dsn: process.env.SENTRY_DSN,
  integrations: [
    // Enable HTTP calls tracing
    new Sentry.Integrations.Http({ tracing: true }),
    // Enable Express request tracing
    new Tracing.Integrations.Express({ app })
  ],
  tracesSampleRate: 1.0, // Sample 100% of transactions in dev/staging
  environment: process.env.NODE_ENV
});

// RequestHandler creates req.sentry: trace/span
app.use(Sentry.Handlers.requestHandler());
// TracingHandler adds tracing data to the transaction
app.use(Sentry.Handlers.tracingHandler());

// Your routes go here
app.get('/', (req, res) => res.send('Hello World!'));
app.get('/error', (req, res) => {
  throw new Error('This is a test error');
});

// Sentry error handler - must be before other error handlers
app.use(Sentry.Handlers.errorHandler());

// Custom error handler
app.use(errorHandler);

app.listen(3000, () => {
  console.log('Server started on port 3000');
});
                

This example demonstrates integrating a popular error monitoring service (Sentry) to track errors in production, including request context and transaction tracing.

Production Error Monitoring Best Practices

Effective error monitoring in production environments requires a comprehensive approach:

  1. Centralized Logging: Use services like ELK Stack (Elasticsearch, Logstash, Kibana), Splunk, or cloud providers' logging solutions
  2. Error Tracking: Implement tools like Sentry, Rollbar, or Bugsnag to track and group errors
  3. Performance Monitoring: Use APM (Application Performance Monitoring) solutions like New Relic, Datadog, or Dynatrace
  4. Alerting: Set up alerts for critical errors and anomalies
  5. Contextual Information: Include enough context (user, request, environment, etc.) to reproduce issues
  6. Sampling and Filtering: For high-traffic applications, implement sampling to avoid overwhelming monitoring systems

Companies with robust error handling practices often implement a multi-layered approach, combining several of these techniques to ensure comprehensive error visibility.

User-Friendly Error Responses

Error responses should be helpful to both API consumers and end users:

API Error Response Structure


// Example of a well-structured error response
{
  "success": false,
  "error": {
    "message": "Validation failed",
    "code": "VALIDATION_ERROR",
    "details": [
      {
        "field": "email",
        "message": "Email format is invalid"
      },
      {
        "field": "password",
        "message": "Password must be at least 8 characters"
      }
    ],
    "documentation": "https://api.example.com/docs/errors#validation"
  },
  "requestId": "7f77cc43-2019-4b55-9c7f-3b97238e0810"
}
                

This example shows a user-friendly error response structure that includes a clear message, error code, detailed validation errors, a link to documentation, and a request ID for support reference.

Localized Error Messages


// Middleware to detect user's language
const languageDetector = (req, res, next) => {
  // Get language from query param, header, or default to English
  req.language = req.query.lang || 
                 req.headers['accept-language']?.split(',')[0]?.split('-')[0] ||
                 'en';
  next();
};

// Error messages by language and code
const errorMessages = {
  en: {
    NOT_FOUND: 'Resource not found',
    UNAUTHORIZED: 'Authentication required',
    FORBIDDEN: 'You do not have permission to access this resource',
    VALIDATION_ERROR: 'Validation failed',
    SERVER_ERROR: 'An unexpected error occurred'
  },
  es: {
    NOT_FOUND: 'Recurso no encontrado',
    UNAUTHORIZED: 'Autenticación requerida',
    FORBIDDEN: 'No tienes permiso para acceder a este recurso',
    VALIDATION_ERROR: 'La validación ha fallado',
    SERVER_ERROR: 'Ocurrió un error inesperado'
  },
  fr: {
    NOT_FOUND: 'Ressource non trouvée',
    UNAUTHORIZED: 'Authentification requise',
    FORBIDDEN: 'Vous n\'avez pas la permission d\'accéder à cette ressource',
    VALIDATION_ERROR: 'La validation a échoué',
    SERVER_ERROR: 'Une erreur inattendue s\'est produite'
  }
};

// Enhanced error handler with localization
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const errorCode = err.code || 
                   (statusCode === 404 ? 'NOT_FOUND' : 
                    statusCode === 401 ? 'UNAUTHORIZED' : 
                    statusCode === 403 ? 'FORBIDDEN' : 
                    statusCode === 422 ? 'VALIDATION_ERROR' : 
                    'SERVER_ERROR');
  
  // Get message for user's language (fall back to English if not available)
  const language = req.language || 'en';
  const messages = errorMessages[language] || errorMessages.en;
  const message = messages[errorCode] || err.message;
  
  // Build response
  const errorResponse = {
    success: false,
    error: {
      message,
      code: errorCode
    },
    requestId: req.id
  };
  
  // Add details if available
  if (err.details) {
    errorResponse.error.details = err.details;
  }
  
  res.status(statusCode).json(errorResponse);
});
                

This example demonstrates how to implement localized error messages based on the user's preferred language.

Error Response Guidelines

When designing error responses for APIs, follow these best practices:

  • Be Consistent: Use the same error structure throughout your API
  • Be Clear: Provide human-readable messages that explain what went wrong
  • Be Specific: Include enough detail to help users fix the problem
  • Be Secure: Don't leak sensitive information in error messages
  • Be Helpful: Provide links to documentation or suggestions for resolution
  • Be Traceable: Include a unique request ID for support reference

Companies like Stripe, GitHub, and Twilio are known for their excellent error handling in their APIs, which contributes significantly to their developer experience.

Error Prevention Strategies

While handling errors is important, preventing them is even better. Here are some strategies to reduce errors in your applications:

Input Validation

Thorough validation of all inputs is the first line of defense:

Database Error Prevention

Prevent common database-related errors:

Asynchronous Code Patterns

Use patterns that reduce the risk of asynchronous errors:

Defensive Programming Techniques


// Null checking with default values
const getName = (user) => {
  return (user && user.name) || 'Unknown User';
};

// Using optional chaining and nullish coalescing
const getNestedValue = (obj) => {
  return obj?.nested?.value ?? 'Default Value';
};

// Graceful handling of potentially missing functions
const callIfExists = (obj, method, ...args) => {
  if (obj && typeof obj[method] === 'function') {
    return obj[method](...args);
  }
  return null;
};

// Safe JSON parsing
const safeJsonParse = (str) => {
  try {
    return JSON.parse(str);
  } catch (e) {
    return null;
  }
};

// Fail-fast with preconditions
const transferFunds = (fromAccount, toAccount, amount) => {
  // Validate inputs before processing
  if (!fromAccount || !toAccount) {
    throw new Error('Both accounts are required');
  }
  
  if (typeof amount !== 'number' || amount <= 0) {
    throw new Error('Amount must be a positive number');
  }
  
  if (fromAccount.balance < amount) {
    throw new Error('Insufficient funds');
  }
  
  // Proceed with transfer...
};
                

This example demonstrates various defensive programming techniques that help prevent errors by handling edge cases and ensuring inputs are valid.

Circuit Breaker Pattern

The circuit breaker pattern is a design pattern used to detect failures and prevent cascade failures in distributed systems:

stateDiagram-v2 [*] --> Closed Closed --> Open: Failure threshold reached Open --> HalfOpen: Timeout expiry HalfOpen --> Closed: Success threshold reached HalfOpen --> Open: Failure occurs state Closed { [*] --> Execute Execute --> Success: Operation succeeds Execute --> Failure: Operation fails Failure --> IncreaseFailureCount Success --> ResetFailureCount } state Open { [*] --> Reject Reject --> StartTimer } state HalfOpen { [*] --> AllowLimited AllowLimited --> MonitorResults }

Circuit breakers are especially useful in microservice architectures, where they can prevent a failure in one service from affecting others. Libraries like Hystrix (Java) and opossum (Node.js) implement this pattern.

Simple Circuit Breaker Implementation


// circuit-breaker.js
class CircuitBreaker {
  constructor(request, options = {}) {
    this.request = request;
    this.state = 'CLOSED';
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000; // 30 seconds
    this.failureCount = 0;
    this.lastFailureTime = null;
    this.fallbackFunction = options.fallback || null;
  }
  
  async fire(...args) {
    if (this.state === 'OPEN') {
      // Check if it's time to try again
      if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
        this.state = 'HALF_OPEN';
      } else {
        return this._fallback(...args);
      }
    }
    
    try {
      const response = await this.request(...args);
      
      // Reset failure count on success
      if (this.state === 'HALF_OPEN') {
        this.state = 'CLOSED';
      }
      this.failureCount = 0;
      
      return response;
    } catch (error) {
      return this._handleFailure(error, ...args);
    }
  }
  
  _handleFailure(error, ...args) {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    
    if (this.state === 'HALF_OPEN' || this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
    }
    
    return this._fallback(error, ...args);
  }
  
  _fallback(error, ...args) {
    if (this.fallbackFunction) {
      return this.fallbackFunction(error, ...args);
    }
    
    throw error;
  }
}

// Example usage
const axios = require('axios');

// Wrap an external API call with a circuit breaker
const getUser = new CircuitBreaker(
  // The request function
  async (userId) => {
    const response = await axios.get(`https://api.example.com/users/${userId}`);
    return response.data;
  },
  // Options
  {
    failureThreshold: 3,
    resetTimeout: 10000, // 10 seconds
    fallback: (error, userId) => {
      console.log(`Circuit is OPEN for getUser, serving fallback for ${userId}`);
      // Return cached data or default
      return { id: userId, name: 'Default User', isDefault: true };
    }
  }
);

// Using the circuit breaker
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await getUser.fire(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);
  }
});
                

This example demonstrates a simple circuit breaker implementation that can wrap external service calls, providing fallback responses when the service is failing and automatically recovering when it becomes available again.

Practical Exercise: Comprehensive Error Handling

Let's apply what we've learned by implementing comprehensive error handling for an Express application:

Robust Error Handling System

Objective: Create a complete error handling system for an Express API with proper error classification, logging, monitoring, and user-friendly responses.

Requirements:

  1. Create a set of custom error classes for different error scenarios
  2. Implement middleware for specific error types (validation, authentication, etc.)
  3. Create a central error handler that formats responses consistently
  4. Add structured logging with context for all errors
  5. Implement a circuit breaker for external API calls
  6. Add proper error handling for asynchronous routes
  7. Include request ID tracking for error correlation

Project Structure:


error-handling-exercise/
├── package.json
├── server.js
├── errors/
│   ├── index.js
│   ├── ApiError.js
│   ├── ValidationError.js
│   ├── NotFoundError.js
│   ├── AuthError.js
│   └── DatabaseError.js
├── middleware/
│   ├── requestId.js
│   ├── errorHandler.js
│   ├── notFound.js
│   └── asyncHandler.js
├── utils/
│   ├── logger.js
│   └── circuitBreaker.js
├── controllers/
│   ├── userController.js
│   └── productController.js
└── routes/
    ├── userRoutes.js
    └── productRoutes.js
                

Bonus Challenge: Add error monitoring integration (e.g., Sentry) and implement localized error messages for multiple languages.

Further Resources