Phase 2: Error Handling

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

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.

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');

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) => {
  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);
});

Let's break down what's happening in this 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) => {
  // 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);
});

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"
}

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) => {
  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
  });
});

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.

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');
const morgan = require('morgan');
const cors = require('cors');
const csurf = require('csurf');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const { ValidationError } = require('sequelize');

const { environment } = require('./config');
const isProduction = environment === 'production';
const routes = require('./routes');

const app = express();

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

// Parse cookies and JSON bodies
app.use(cookieParser());
app.use(express.json());

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

// Helmet helps set security headers
app.use(
  helmet.crossOriginResourcePolicy({
    policy: "cross-origin"
  })
);

// Set the _csrf token and create req.csrfToken method
app.use(
  csurf({
    cookie: {
      secure: isProduction,
      sameSite: isProduction && "Lax",
      httpOnly: true
    }
  })
);

// Connect routes
app.use(routes);

// Catch unhandled requests and forward to error handler.
app.use((_req, _res, next) => {
  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) => {
  // 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) => {
  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
  });
});

module.exports = app;

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.

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.

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 Issues and Solutions

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.