When building robust RESTful APIs, proper error handling is important for maintaining, debugging, and providing clear feedback to API consumers. In this comprehensive guide, we'll explore how to implement advanced error handling in a Node.js API using Express.
Table of Contents
Setting Up the Project
First, let's set up a basic Express project with necessary dependencies:
const express = require('express');
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
Custom Error Classes
Create a hierarchy of custom error classes to handle different types of errors:
// Base error class for operational errors
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
// Specific error classes
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
}
}
class NotFoundError extends AppError {
constructor(message) {
super(message, 404);
}
}
class UnauthorizedError extends AppError {
constructor(message) {
super(message, 401);
}
}
Error Handling Middleware
Implement a central error handling middleware:
// Error handling middleware
const errorHandler = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
if (process.env.NODE_ENV === 'development') {
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
} else {
// Production error response
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message
});
} else {
// Programming or unknown errors
console.error('ERROR 💥', err);
res.status(500).json({
status: 'error',
message: 'Something went wrong!'
});
}
}
};
// Async error wrapper
const catchAsync = fn => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
Implementation Examples
Let's implement some routes with error handling:
// User routes with error handling
const router = express.Router();
// Get user by ID
router.get('/users/:id', catchAsync(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User not found');
}
res.status(200).json({
status: 'success',
data: { user }
});
}));
// Create user with validation
router.post('/users', catchAsync(async (req, res, next) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
throw new ValidationError('Please provide all required fields');
}
const existingUser = await User.findOne({ email });
if (existingUser) {
throw new ValidationError('Email already exists');
}
const user = await User.create({ name, email, password });
res.status(201).json({
status: 'success',
data: { user }
});
}));
// Protected route example
router.get('/protected', authenticate, catchAsync(async (req, res, next) => {
if (!req.user) {
throw new UnauthorizedError('Please log in to access this route');
}
// Route logic here
}));
Database Error Handling
Handle common database errors:
// MongoDB error handler
const handleMongoError = (error) => {
if (error.name === 'CastError') {
return new AppError(`Invalid ${error.path}: ${error.value}`, 400);
}
if (error.code === 11000) {
const field = Object.keys(error.keyValue)[0];
return new AppError(`Duplicate field value: ${field}`, 400);
}
if (error.name === 'ValidationError') {
const errors = Object.values(error.errors).map(err => err.message);
return new AppError(`Invalid input data: ${errors.join('. ')}`, 400);
}
return error;
};
// Apply to error middleware
app.use((err, req, res, next) => {
if (err.name === 'MongoError' || err.name === 'ValidationError') {
err = handleMongoError(err);
}
errorHandler(err, req, res, next);
});
Request Validation Example
Implement request validation using a middleware:
const validateRequest = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
throw new ValidationError(error.details[0].message);
}
next();
};
};
// Usage example with Joi
const userSchema = Joi.object({
name: Joi.string().required().min(3),
email: Joi.string().email().required(),
password: Joi.string().required().min(6)
});
router.post('/users', validateRequest(userSchema), catchAsync(async (req, res) => {
// Route logic here
}));
Best Practices
Centralized Error Handling
Use a single error handling middleware
Implement custom error classes for different scenarios
Keep error responses consistent
Security Considerations
Hide sensitive error details in production
Log errors properly
Implement rate limiting
// Rate limiting example
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
max: 100, // max requests
windowMs: 60 * 60 * 1000, // 1 hour
message: 'Too many requests from this IP, please try again in an hour'
});
app.use('/api', limiter);
Logging
- Implement proper error logging
const winston = require('winston');
const logger = winston.createLogger({
level: 'error',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.Console({
format: winston.format.simple()
})
]
});
// Use in error handler
const errorHandler = (err, req, res, next) => {
logger.error('Error 💥', {
error: err,
stack: err.stack
});
// ... rest of error handling logic
};
Complete Application Setup
Here's how to put it all together:
const express = require('express');
const mongoose = require('mongoose');
require('dotenv').config();
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/v1/users', userRoutes);
// Error handling
app.all('*', (req, res, next) => {
next(new NotFoundError(`Can't find ${req.originalUrl} on this server!`));
});
app.use(errorHandler);
// Server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
// Unhandled rejection handler
process.on('unhandledRejection', (err) => {
console.log('UNHANDLED REJECTION! 💥 Shutting down...');
console.log(err.name, err.message);
server.close(() => {
process.exit(1);
});
});
Testing Your Error Handling
Here's a simple test suite using Jest:
const request = require('supertest');
const app = require('../app');
describe('Error Handling', () => {
test('should handle 404 errors', async () => {
const response = await request(app)
.get('/api/v1/nonexistent')
.expect(404);
expect(response.body).toHaveProperty('status', 'fail');
expect(response.body).toHaveProperty('message');
});
test('should handle validation errors', async () => {
const response = await request(app)
.post('/api/v1/users')
.send({})
.expect(400);
expect(response.body).toHaveProperty('status', 'fail');
expect(response.body.message).toContain('required fields');
});
});
Conclusion
Implementing robust error handling in your Node.js RESTful API is crucial for maintaining a reliable and developer-friendly service. By following these patterns and best practices, you can create a system that gracefully handles errors, provides meaningful feedback to clients, and makes debugging easier.
Remember to:
Use custom error classes for different scenarios
Implement centralized error handling
Validate requests properly
Handle async errors consistently
Log errors appropriately
Consider security implications
This implementation provides a solid foundation for handling errors in your Node.js applications and can be extended based on your specific needs.