Phase 2: Error Handling (With Detailed Comments)

← Back to Main Tutorial

Understanding the Problem

In this phase, we need to set up error handling middleware for our Express application. Error handling is crucial for a robust API as it allows us to:

Without proper error handling, our API would return generic responses that aren't helpful for debugging or client-side error handling. Good error handling provides clear feedback about what went wrong and potentially how to fix it.

Why error handling matters: Proper error handling is a critical aspect of production-quality APIs. It improves:

  • User experience - Clear, actionable error messages help users understand problems
  • Developer experience - Consistent error formats make client-side error handling simpler
  • Security - Prevents leaking sensitive information in error messages
  • Debugging - Makes it easier to track down issues in development

Without it, your application would fall back to Express's default error handler, which is not suitable for production use.

Planning the Solution

  1. Create a "Resource Not Found" middleware to catch unhandled routes
  2. Create a Sequelize error handler middleware to format database errors
  3. Create a general error formatter middleware to standardize error responses
  4. Test the error handling by attempting to access non-existent routes

We'll implement these middlewares in our app.js file, after the routes are connected. Remember that the order of middlewares in Express is important - the error handling middlewares should be defined last.

Middleware order in Express: Express processes middleware in the order they're defined. Error-handling middleware (functions with 4 parameters: err, req, res, next) are special and will only be called when an error occurs or when next(err) is called.

Our implementation will use a chain of error middleware:

  1. 404 middleware: Catches routes that don't match any defined endpoints
  2. Sequelize error middleware: Formats database validation errors
  3. General error middleware: Standardizes all error responses

Each middleware can process the error and pass it to the next in the chain, allowing for specialized handling of different error types.

Implementing the Solution

1. Import Required Dependencies

First, we need to import the ValidationError class from Sequelize. Add this to the top of your app.js file with the other imports:

// backend/app.js
// ... other imports
const { ValidationError } = require('sequelize');  // Import Sequelize's ValidationError class

ValidationError: This is Sequelize's built-in error class that's thrown when a model validation fails. By importing it, we can check if an error is specifically a Sequelize validation error using instanceof.

Common scenarios when ValidationError is thrown:

  • Required fields are missing
  • String length constraints are violated
  • Data type constraints are violated
  • Custom model validators fail
  • Unique constraint violations (though these typically throw SequelizeUniqueConstraintError, a subclass)

2. Resource Not Found Error-Handler

This middleware will catch any requests that don't match any of your defined routes. It creates a 404 error with a descriptive message. Add this after your routes are connected (after app.use(routes)):

// Catch unhandled requests and forward to error handler.
app.use((_req, _res, next) => {                   // Regular middleware with 3 parameters
  const err = new Error("The requested resource couldn't be found.");  // Create new Error object
  err.title = "Resource Not Found";               // Add custom title property
  err.errors = { message: "The requested resource couldn't be found." };  // Add structured errors object
  err.status = 404;                              // Set HTTP status code to 404 Not Found
  next(err);                                     // Pass error to next error handler
});

Let's break down what's happening in this middleware:

How this middleware works: If a request doesn't match any defined routes, it "falls through" to this middleware. Since this middleware appears after all your route definitions, it only runs when no route handlers matched the request.

Custom error properties: Standard JavaScript Error objects only have message and stack properties. We're adding:

  • title: A human-readable title for the error
  • errors: An object that can contain field-specific error messages
  • status: The HTTP status code to use in the response

next(err): Calling next with an argument tells Express that an error occurred and it should skip regular middleware and go straight to error-handling middleware.

3. Sequelize Error-Handler

This middleware specifically handles Sequelize validation errors, formatting them in a consistent way. Add this after the "Resource Not Found" middleware:

// Process sequelize errors
app.use((err, _req, _res, next) => {                // Error middleware has 4 parameters
  // check if error is a Sequelize error:
  if (err instanceof ValidationError) {             // Check if error is from Sequelize validation
    let errors = {};
    for (let error of err.errors) {                 // Loop through each validation error
      errors[error.path] = error.message;           // Map field name to error message
    }
    err.title = 'Validation error';                 // Set error title
    err.errors = errors;                            // Replace errors with our formatted version
  }
  next(err);                                        // Pass to next error handler
});

This middleware checks if the error is an instance of Sequelize's ValidationError. If it is, it reformats the error messages into a more user-friendly format, where each property corresponds to a field with a validation error. For example:

{
  "email": "Email is required",
  "username": "Username must be at least 4 characters long"
}

Error-handling middleware: Note the function signature (err, _req, _res, next) with four parameters. Express recognizes this pattern and only calls this middleware when an error occurs.

Sequelize ValidationError structure: When Sequelize validation fails, it provides an array of errors in err.errors, where each error has:

  • path: The field/column name that failed validation
  • message: The validation error message
  • type: The type of validation that failed
  • value: The value that failed validation

Our transformation: We convert this array into an object where keys are field names and values are error messages. This format is more convenient for client-side handling, as you can directly access errors.email rather than searching through an array.

4. Error Formatter Middleware

The final error handler middleware formats all errors into a consistent JSON response. Add this after the Sequelize error handler:

// Error formatter
app.use((err, _req, res, _next) => {                // Final error middleware
  res.status(err.status || 500);                    // Set HTTP status code (default to 500)
  console.error(err);                               // Log error to server console
  res.json({                                        // Send JSON response with error details
    title: err.title || 'Server Error',             // Use custom title or default
    message: err.message,                           // Original error message
    errors: err.errors,                             // Field-specific errors object
    stack: isProduction ? null : err.stack          // Only include stack trace in development
  });
});

This middleware does several important things:

This creates a consistent error response format across your entire API, making it easier for clients to handle errors.

Environment-specific behavior: Note how we use isProduction to determine whether to include the stack trace. This is an important security practice:

  • In development: Include the stack trace to help developers debug issues
  • In production: Omit the stack trace as it could reveal implementation details and potential vulnerabilities

Standard error response structure: By standardizing on a single error format, we make it easier for clients to handle errors consistently. A frontend application might use code like:

fetch('/api/resource')
  .then(response => {
    if (!response.ok) {
      return response.json().then(err => {
        throw err; // Convert error response to rejection
      });
    }
    return response.json();
  })
  .then(data => {
    // Handle success...
  })
  .catch(err => {
    // Handle standardized error format
    if (err.errors) {
      // Handle field-specific errors
      Object.entries(err.errors).forEach(([field, message]) => {
        showFieldError(field, message);
      });
    } else {
      // Handle general error
      showGeneralError(err.message || 'An error occurred');
    }
  });

5. Complete Error Handling Implementation

Your app.js file should now have all three error handling middlewares added after the routes. Make sure they're in the correct order:

// backend/app.js
const express = require('express');
require('express-async-errors');                    // Automatically catches errors in async route handlers
const morgan = require('morgan');                   // HTTP request logger
const cors = require('cors');                       // Cross-Origin Resource Sharing
const csurf = require('csurf');                     // CSRF protection
const helmet = require('helmet');                   // Security headers
const cookieParser = require('cookie-parser');      // Parse cookies from requests
const { ValidationError } = require('sequelize');   // Import Sequelize validation error

const { environment } = require('./config');        // Import environment configuration
const isProduction = environment === 'production';  // Check if we're in production
const routes = require('./routes');                 // Import routes

const app = express();                              // Create Express application

// Connect morgan middleware for logging
app.use(morgan('dev'));                             // Log HTTP requests

// Parse cookies and JSON bodies
app.use(cookieParser());                            // Parse Cookie header and populate req.cookies
app.use(express.json());                            // Parse JSON request bodies

// Security middleware
if (!isProduction) {
  // Enable CORS only in development
  app.use(cors());                                  // Allow cross-origin requests in development
}

// Helmet helps set security headers
app.use(
  helmet.crossOriginResourcePolicy({
    policy: "cross-origin"                          // Allow loading resources from different origins
  })
);

// Set the _csrf token and create req.csrfToken method
app.use(
  csurf({
    cookie: {
      secure: isProduction,                         // HTTPS only in production
      sameSite: isProduction && "Lax",              // Controls when cookies are sent
      httpOnly: true                                // Prevents JavaScript access to the cookie
    }
  })
);

// Connect routes
app.use(routes);                                    // Use our defined routes

// --- ERROR HANDLING MIDDLEWARE BELOW ---

// Catch unhandled requests and forward to error handler.
app.use((_req, _res, next) => {                     // 404 Not Found handler
  const err = new Error("The requested resource couldn't be found.");
  err.title = "Resource Not Found";
  err.errors = { message: "The requested resource couldn't be found." };
  err.status = 404;
  next(err);
});

// Process sequelize errors
app.use((err, _req, _res, next) => {                // Sequelize error handler
  // check if error is a Sequelize error:
  if (err instanceof ValidationError) {
    let errors = {};
    for (let error of err.errors) {
      errors[error.path] = error.message;
    }
    err.title = 'Validation error';
    err.errors = errors;
  }
  next(err);
});

// Error formatter
app.use((err, _req, res, _next) => {                // General error formatter
  res.status(err.status || 500);
  console.error(err);
  res.json({
    title: err.title || 'Server Error',
    message: err.message,
    errors: err.errors,
    stack: isProduction ? null : err.stack          // Only include stack in development
  });
});

module.exports = app;

Complete middleware stack: This file shows the complete middleware configuration for the Express application, including:

  1. Request processing middleware: Logging, parsing, security
  2. Route handling: All application routes
  3. Error handling middleware: 404 handler, Sequelize error formatter, general error formatter

express-async-errors: Note the require('express-async-errors') near the top. This library extends Express to automatically catch errors thrown in async route handlers and pass them to your error middleware, eliminating the need for try/catch blocks in every async route.

6. Testing the Error Handlers

Let's test our error handling middleware to ensure it's working correctly. Start your server if it's not already running:

cd backend
npm start

Now, try accessing a route that doesn't exist in your application. Open your browser and navigate to:

http://localhost:8000/not-found

You should see a JSON response like:

{
  "title": "Resource Not Found",
  "message": "The requested resource couldn't be found.",
  "errors": {
    "message": "The requested resource couldn't be found."
  },
  "stack": "Error: The requested resource couldn't be found.\n ..."
}

This confirms that your "Resource Not Found" error handler is working correctly.

Also, make sure your CSRF route is still working by navigating to:

http://localhost:8000/api/csrf/restore

You should see a JSON response with a CSRF token, indicating that your regular routes are still functioning correctly alongside the error handlers.

We'll test the Sequelize error handler later once we have implemented database models.

Testing error handlers: It's important to test error handlers to ensure they behave as expected. Here are some testing approaches:

  • Manual testing: Access non-existent routes in the browser as shown above
  • API client testing: Use tools like Postman or Insomnia to test error responses
  • Automated testing: Write tests using frameworks like Jest or Mocha that intentionally trigger errors

Testing Sequelize validation errors: Once you have models set up, you can test validation errors by:

  1. Creating a test endpoint that attempts to create a model with invalid data
  2. Sending a request to that endpoint
  3. Verifying that you receive a properly formatted error response

For example, if you have a User model with a required email field, you could test by attempting to create a user without an email.

Review the Solution

Let's review what we've accomplished in this phase:

Why This Approach Works Well

This error handling approach has several advantages:

  1. Centralized error handling: Instead of handling errors in each route handler, we've centralized the logic, making it easier to maintain.
  2. Consistent error responses: All errors now follow the same format, making it easier for client applications to handle them.
  3. Environment-aware: We only include stack traces in development environments, improving security in production.
  4. Specialized handling: Different types of errors (like Sequelize validation errors) get specialized formatting.

Benefits of centralized error handling:

  • DRY (Don't Repeat Yourself): Avoid duplicating error handling code in every route
  • Consistency: All errors are formatted the same way, regardless of where they originate
  • Separation of concerns: Route handlers focus on the happy path, while error handlers deal with problems
  • Maintainability: Changes to error handling can be made in one place

Alternative approaches: Other common error handling patterns include:

  • Try/catch in each route handler: More verbose but gives more control at the route level
  • Error handling libraries: Packages like 'express-error-handler' or 'http-errors' can simplify error handling
  • Custom error classes: Creating specific error classes for different types of errors

Our approach strikes a good balance between simplicity and functionality while maintaining the flexibility to handle different error types.

Real-world Application

In real-world applications, robust error handling is crucial for several reasons:

For example, in a user registration system, when a user tries to register with an email that's already taken, they should receive a clear message like "Email is already in use" rather than a generic error or, worse, a stack trace revealing implementation details.

Common types of errors in web applications:

Error Type HTTP Status Example
Validation Error 400 Bad Request Invalid email format, password too short
Authentication Error 401 Unauthorized Invalid credentials, expired token
Authorization Error 403 Forbidden User doesn't have permission for the action
Resource Not Found 404 Not Found Requested user, product, or route doesn't exist
Conflict Error 409 Conflict Email already in use, duplicate resource
Server Error 500 Internal Server Error Database connection failed, unhandled exception

Our error handling system can be extended to handle each of these error types with appropriate status codes and messages.

Common Issues and Solutions

Debugging error handling: When your error handling isn't working as expected, try these steps:

  1. Add console.log statements in each error middleware to see if they're being called
  2. Check the order of middleware definitions - remember, order matters in Express
  3. Verify environment variables to ensure isProduction is set correctly
  4. Test with different error types to see how they're handled
  5. Check your error propagation - make sure errors are being passed to next()

Common mistake: A frequent error is forgetting to call next(err) in error middleware, which prevents the error from propagating to subsequent error handlers.

Next Steps

Now that you have set up error handling for your application, you are ready to move on to the next phase: Phase 3 User Authentication. In that phase, you'll:

With solid error handling in place, you'll be able to provide clear feedback to users when authentication-related issues occur.

Looking ahead: In Phase 3, you'll build upon this error handling system to create specific error responses for authentication scenarios:

  • Login validation errors (invalid credentials)
  • Registration validation errors (email taken, invalid fields)
  • Authorization errors (not logged in, insufficient permissions)
  • Token validation errors (expired or invalid JWT)

The error handling system you've built will make these authentication errors more informative and easier to handle for both developers and end users.