Implementing a RESTful API with Advanced Error Handling in Node.js

Implementing a RESTful API with Advanced Error Handling in Node.js

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

Download Now

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

  1. Centralized Error Handling

    • Use a single error handling middleware

    • Implement custom error classes for different scenarios

    • Keep error responses consistent

  2. 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);
  1. 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.

Reactjs Template

Download Now