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:
- Catch and respond to requests for non-existent routes
- Format Sequelize database errors consistently
- Format and customize error responses for better client-side handling
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
- Create a "Resource Not Found" middleware to catch unhandled routes
- Create a Sequelize error handler middleware to format database errors
- Create a general error formatter middleware to standardize error responses
- 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:
- 404 middleware: Catches routes that don't match any defined endpoints
- Sequelize error middleware: Formats database validation errors
- 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:
- The underscore prefix in
_reqand_resindicates that these parameters are not used in the function. - We create a new Error object with a descriptive message.
- We add custom properties to the error object:
title,errors, andstatus. - Finally, we call
next(err)to pass the error to the next error-handling 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 errorerrors: An object that can contain field-specific error messagesstatus: 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 validationmessage: The validation error messagetype: The type of validation that failedvalue: 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:
- Sets the HTTP status code of the response based on the error's status property (defaulting to 500 if not specified)
- Logs the error to the console for server-side debugging
- Formats the response as JSON with consistent fields: title, message, errors, and stack
- Only includes the stack trace in development environments, not in production
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:
- Request processing middleware: Logging, parsing, security
- Route handling: All application routes
- 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:
- Creating a test endpoint that attempts to create a model with invalid data
- Sending a request to that endpoint
- 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:
- Created a "Resource Not Found" middleware that catches requests to non-existent routes
- Implemented a Sequelize error handler that formats database validation errors
- Added a general error formatter middleware that standardizes all error responses
- Successfully tested the "Resource Not Found" error handler
Why This Approach Works Well
This error handling approach has several advantages:
- Centralized error handling: Instead of handling errors in each route handler, we've centralized the logic, making it easier to maintain.
- Consistent error responses: All errors now follow the same format, making it easier for client applications to handle them.
- Environment-aware: We only include stack traces in development environments, improving security in production.
- 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:
- User experience: Clear error messages help users understand what went wrong and potentially how to fix it.
- Debugging: Detailed error information in development environments makes it easier to debug issues.
- Security: Proper error handling prevents leaking sensitive information in production.
- API consistency: A consistent error format makes it easier for client applications to handle errors.
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
- Error handlers not being called: Make sure your error handlers are defined after all your routes and middleware.
- Routes still working after error handlers: If you define routes after your error handlers, they won't be caught by the "Resource Not Found" middleware.
- Sequelize errors not being formatted: Ensure you're importing ValidationError from sequelize and checking for instanceof correctly.
- Stack traces appearing in production: Double-check that your isProduction variable is correctly set based on the NODE_ENV environment variable.
Debugging error handling: When your error handling isn't working as expected, try these steps:
- Add console.log statements in each error middleware to see if they're being called
- Check the order of middleware definitions - remember, order matters in Express
- Verify environment variables to ensure isProduction is set correctly
- Test with different error types to see how they're handled
- 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:
- Create a Users table with Sequelize
- Set up user model with validations
- Implement authentication utilities with JWT
- Create authentication middleware
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.
Why error handling matters: Proper error handling is a critical aspect of production-quality APIs. It improves:
Without it, your application would fall back to Express's default error handler, which is not suitable for production use.