The Challenge of Asynchronous Errors
Asynchronous programming is fundamental to Node.js and Express, but it introduces unique challenges for error handling. When errors occur in asynchronous code, they don't follow the same flow as synchronous errors, making them harder to catch and manage properly.
"Asynchronous errors are like ghosts in your application: they appear when you least expect them, and traditional error-catching mechanisms won't help you find them."
Why Asynchronous Error Handling Is Different
- Execution Context: Asynchronous operations execute outside the original call stack
- Timing: Errors occur after the original function has returned
- Propagation: Errors don't naturally propagate up the call stack
- Express Middleware: Standard Express error middleware won't catch async errors automatically
The Problem with Asynchronous Code in Express
To understand why asynchronous error handling is challenging, let's examine the typical asynchronous patterns in Express applications.
Callbacks
Traditional Node.js callback pattern:
app.get('/users/:id', (req, res) => {
// Asynchronous operation with callback
database.getUser(req.params.id, (err, user) => {
// Error in callback won't be caught by Express error handler
if (err) {
// Need manual error handling
console.error(err);
return res.status(500).json({ error: 'Database error' });
}
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
});
Promises
Using promises without proper error handling:
app.get('/users/:id', (req, res) => {
// Promise-based asynchronous operation
database.getUserPromise(req.params.id)
.then(user => {
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
})
.catch(err => {
// Need to handle errors manually
console.error(err);
res.status(500).json({ error: 'Database error' });
});
});
Async/Await
Using async/await without catching errors:
app.get('/users/:id', async (req, res) => {
// Async/await without try/catch
const user = await database.getUserAsync(req.params.id);
// If getUserAsync throws, the error won't be caught by Express
// and will cause an unhandled promise rejection
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
Why Express Doesn't Catch Asynchronous Errors
Express was designed before promises and async/await became standard in JavaScript. Its error handling middleware system works with the traditional synchronous middleware pattern:
function middleware(req, res, next) {
// If an error is thrown synchronously here, Express will catch it
throw new Error('Sync error'); // This will be caught
// But if an error happens in an async callback,
// it occurs after this function has returned
setTimeout(() => {
throw new Error('Async error'); // This won't be caught by Express
}, 100);
// Similarly with promises
Promise.resolve().then(() => {
throw new Error('Promise error'); // This won't be caught by Express
});
}
This behavior creates a significant gap in Express's error handling capabilities when dealing with modern asynchronous JavaScript.
Strategies for Handling Callback Errors
Even though promises and async/await are now preferred, many Node.js libraries still use callbacks. Here's how to handle errors properly with callbacks.
Pattern 1: Explicit Error Handling
Explicitly check for errors in each callback:
app.get('/users/:id', (req, res, next) => {
database.getUser(req.params.id, (err, user) => {
if (err) {
// Pass the error to Express error handler
return next(err);
}
if (!user) {
// Create a custom error
const error = new Error('User not found');
error.statusCode = 404;
return next(error);
}
res.json(user);
});
});
Pattern 2: Error-First Callback Wrapper
Create a utility to handle error-first callbacks:
// utils/callbackHandler.js
function handleCallback(req, res, next) {
return function(err, data) {
if (err) {
return next(err);
}
return data;
};
}
// Usage
app.get('/users/:id', (req, res, next) => {
database.getUser(
req.params.id,
handleCallback(req, res, next, (user) => {
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
return next(error);
}
res.json(user);
})
);
});
Pattern 3: Promisify Callbacks
Convert callback-based functions to promises:
// utils/promisify.js
const { promisify } = require('util');
// Promisify specific function
const getUserPromise = promisify(database.getUser);
// Or promisify all methods in an object
const database = {
getUser: promisify(originalDatabase.getUser),
createUser: promisify(originalDatabase.createUser),
updateUser: promisify(originalDatabase.updateUser),
deleteUser: promisify(originalDatabase.deleteUser)
};
// Usage
app.get('/users/:id', async (req, res, next) => {
try {
const user = await getUserPromise(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
return next(error);
}
res.json(user);
} catch (err) {
next(err);
}
});
Node.js util.promisify
Node.js provides the util.promisify function to convert callback-based functions to promise-based ones. It works with any function that follows the Node.js callback style, where:
- The function's last argument is a callback
- The callback takes an error as its first argument and result as the second
This is a powerful way to modernize legacy code and make error handling more consistent.
Handling Promise-Based Errors
Promises provide a cleaner way to handle asynchronous errors through the .catch() method, but they still need to be integrated properly with Express.
Pattern 1: Manual Promise Handling
Explicit .catch() to pass errors to Express:
app.get('/users/:id', (req, res, next) => {
database.getUserPromise(req.params.id)
.then(user => {
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error; // This will be caught by the .catch() below
}
res.json(user);
})
.catch(err => {
// Pass to Express error handler
next(err);
});
});
Pattern 2: Promise Middleware Wrapper
Create a utility to handle promise-returning route handlers:
// utils/promiseHandler.js
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next))
.catch(next); // Pass any error to Express error middleware
};
}
// Usage
app.get('/users/:id', asyncHandler((req, res) => {
return database.getUserPromise(req.params.id)
.then(user => {
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json(user);
});
}));
Pattern 3: Promise-Aware Error Middleware
Enhance Express's error handling to deal with unhandled promise rejections:
// Enhanced promise error handler
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Optionally terminate the process
// process.exit(1);
});
// Custom middleware to catch promise errors in routes
app.use((err, req, res, next) => {
// Handle the error
console.error(err);
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
error: {
message,
status: statusCode
}
});
});
Real-World Example: Database Query with Error Handling
Consider a user profile API that needs to fetch related data from multiple database tables:
app.get('/users/:id/profile', asyncHandler(async (req, res) => {
const userId = req.params.id;
// Get user data
const user = await db.getUserById(userId);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
// Fetch related data in parallel with Promise.all
try {
const [posts, comments, followers] = await Promise.all([
db.getPostsByUser(userId),
db.getCommentsByUser(userId),
db.getFollowersByUser(userId)
]);
// Combine all data
res.json({
user,
activity: {
posts,
comments
},
social: {
followers
}
});
} catch (error) {
// More specific error based on what failed
const enhancedError = new Error('Failed to fetch profile data');
enhancedError.statusCode = 500;
enhancedError.originalError = error;
throw enhancedError;
}
}));
This example shows how to handle errors at different stages of a complex operation and provide meaningful error information.
Handling Async/Await Errors
Async/await provides the most readable way to work with asynchronous code, but it requires specific error handling patterns for Express.
Pattern 1: Try/Catch in Each Route
Wrap async route handlers in try/catch blocks:
app.get('/users/:id', async (req, res, next) => {
try {
const user = await database.getUserAsync(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
return next(error);
}
res.json(user);
} catch (err) {
next(err); // Pass to Express error handler
}
});
Pattern 2: Async Handler Wrapper
Create a utility to catch errors in async functions:
// utils/catchAsync.js
const catchAsync = fn => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
// Usage
app.get('/users/:id', catchAsync(async (req, res, next) => {
const user = await database.getUserAsync(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
return next(error);
}
res.json(user);
}));
Pattern 3: Express Async Errors Package
Use a library to patch Express to handle async errors automatically:
// Install: npm install express-async-errors
// Add at the very beginning of your app
require('express-async-errors');
const express = require('express');
const app = express();
// Now async errors will be automatically caught
app.get('/users/:id', async (req, res) => {
const user = await database.getUserAsync(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error; // This will be caught and passed to error middleware
}
res.json(user);
});
Pattern 4: Class-Based Route Handlers
Organize routes with class methods and a wrapper:
// controllers/UserController.js
class UserController {
// Get user by ID
async getUser(req, res) {
const user = await database.getUserAsync(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json(user);
}
// Create user
async createUser(req, res) {
const { name, email, password } = req.body;
// Validation
if (!name || !email || !password) {
const error = new Error('Missing required fields');
error.statusCode = 400;
throw error;
}
const user = await database.createUserAsync({ name, email, password });
res.status(201).json(user);
}
}
// Create a wrapper for controller methods
const wrapAsync = (controller, method) => {
return (req, res, next) => {
controller[method](req, res, next).catch(next);
};
};
// routes/userRoutes.js
const controller = new UserController();
router.get('/users/:id', wrapAsync(controller, 'getUser'));
router.post('/users', wrapAsync(controller, 'createUser'));
Creating a Robust catchAsync Utility
Let's build a more advanced version of the catchAsync utility function that can handle different types of handlers.
Enhanced catchAsync Implementation
// utils/catchAsync.js
/**
* Wraps an async function to automatically catch errors and pass them to Express error middleware
* @param {Function} fn - The async route handler function
* @param {Object} options - Options for error handling
* @param {boolean} options.handleResponse - Whether to handle the response to avoid headers already sent errors
* @returns {Function} Express middleware function
*/
const catchAsync = (fn, options = {}) => {
return (req, res, next) => {
// Set default options
const opts = {
handleResponse: true,
...options
};
// Track if response has been sent
let responseSent = false;
// Save original res.json/send/end methods
const originalJson = res.json;
const originalSend = res.send;
const originalEnd = res.end;
if (opts.handleResponse) {
// Override response methods to track if response has been sent
res.json = function(body) {
responseSent = true;
return originalJson.call(this, body);
};
res.send = function(body) {
responseSent = true;
return originalSend.call(this, body);
};
res.end = function(data) {
responseSent = true;
return originalEnd.call(this, data);
};
}
// Execute the async function
Promise.resolve(fn(req, res, next))
.catch(err => {
// Restore original methods
if (opts.handleResponse) {
res.json = originalJson;
res.send = originalSend;
res.end = originalEnd;
}
// Only pass error to next if response hasn't been sent
if (!responseSent) {
next(err);
} else {
console.error('Error occurred after response was sent:', err);
}
});
};
};
module.exports = catchAsync;
Usage with Different Patterns
// Basic usage
app.get('/users/:id', catchAsync(async (req, res) => {
const user = await database.getUserAsync(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json(user);
}));
// With custom error handler
app.get('/products/:id', catchAsync(async (req, res) => {
const product = await database.getProductAsync(req.params.id);
if (!product) {
// Custom error handling
return res.status(404).json({ error: 'Product not found' });
}
res.json(product);
}, { handleResponse: true }));
// With middleware that might respond
app.post('/auth/login', catchAsync(async (req, res, next) => {
const { email, password } = req.body;
// Another middleware might handle the response
if (!email || !password) {
return next(new Error('Missing credentials'));
}
const user = await authService.authenticate(email, password);
res.json({ token: generateToken(user) });
}));
Avoiding "Headers Already Sent" Errors
One common issue with asynchronous error handling is the "Cannot set headers after they are sent to the client" error. This happens when:
- Your route handler sends a response
- Then an asynchronous operation throws an error
- The error handler tries to send another error response
The enhanced catchAsync utility above tracks whether a response has been sent and avoids passing errors to next() if the response has already been sent, preventing this issue.
Advanced Async Error Handling Patterns
Beyond the basics, there are several advanced patterns for handling asynchronous errors in Express applications.
Pattern 1: Router-Level Async Handling
Apply async error handling at the router level:
// routes/asyncRouter.js
const express = require('express');
const catchAsync = require('../utils/catchAsync');
/**
* Creates an Express router with automatic async error handling
* @returns {Router} Express router with async error handling
*/
function createAsyncRouter() {
const router = express.Router();
const methods = ['get', 'post', 'put', 'delete', 'patch'];
// Override router methods to wrap handlers in catchAsync
methods.forEach(method => {
const originalMethod = router[method];
router[method] = function(path, ...handlers) {
const wrappedHandlers = handlers.map(handler => {
// Skip middleware with 4 parameters (error handlers)
if (handler.length === 4) {
return handler;
}
// Wrap async handlers
return catchAsync(handler);
});
// Call original method with wrapped handlers
return originalMethod.call(this, path, ...wrappedHandlers);
};
});
return router;
}
// Usage
const router = createAsyncRouter();
router.get('/users/:id', async (req, res) => {
// No need for try/catch or catchAsync here
const user = await database.getUserAsync(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json(user);
});
module.exports = router;
Pattern 2: Declarative Route Definitions
Define routes with an object-based approach:
// routes/declarativeRoutes.js
const catchAsync = require('../utils/catchAsync');
/**
* Creates route handlers from a declarative object definition
* @param {Object} routeDefinitions - Object with route definitions
* @returns {Object} Object with initialized route handlers
*/
function createRoutes(routeDefinitions) {
const routes = {};
Object.entries(routeDefinitions).forEach(([name, definition]) => {
routes[name] = catchAsync(definition.handler);
});
return routes;
}
// Route definitions
const userRoutes = createRoutes({
getUser: {
method: 'GET',
path: '/users/:id',
handler: async (req, res) => {
const user = await database.getUserAsync(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json(user);
}
},
createUser: {
method: 'POST',
path: '/users',
handler: async (req, res) => {
const user = await database.createUserAsync(req.body);
res.status(201).json(user);
}
}
});
// Apply routes to the router
function applyRoutes(router, routeDefinitions, handlers) {
Object.entries(routeDefinitions).forEach(([name, definition]) => {
const { method, path } = definition;
router[method.toLowerCase()](path, handlers[name]);
});
}
// Usage
const router = express.Router();
applyRoutes(router, userRoutes, handlers);
module.exports = router;
Pattern 3: Domain-Driven Error Handling
Organize error handling by domain:
// domains/user/errors.js
class UserNotFoundError extends Error {
constructor(userId) {
super(`User with ID ${userId} not found`);
this.statusCode = 404;
this.code = 'USER_NOT_FOUND';
this.name = 'UserNotFoundError';
}
}
class UserValidationError extends Error {
constructor(errors) {
super('User validation failed');
this.statusCode = 400;
this.code = 'USER_VALIDATION_FAILED';
this.name = 'UserValidationError';
this.errors = errors;
}
}
module.exports = {
UserNotFoundError,
UserValidationError
};
// domains/user/service.js
const { UserNotFoundError, UserValidationError } = require('./errors');
class UserService {
async getUser(userId) {
const user = await database.getUserAsync(userId);
if (!user) {
throw new UserNotFoundError(userId);
}
return user;
}
async createUser(userData) {
const errors = this.validateUser(userData);
if (Object.keys(errors).length > 0) {
throw new UserValidationError(errors);
}
return await database.createUserAsync(userData);
}
validateUser(userData) {
const errors = {};
if (!userData.name) {
errors.name = 'Name is required';
}
if (!userData.email) {
errors.email = 'Email is required';
} else if (!isValidEmail(userData.email)) {
errors.email = 'Email is invalid';
}
return errors;
}
}
// routes/userRoutes.js
const { UserService } = require('../domains/user/service');
const userService = new UserService();
router.get('/users/:id', catchAsync(async (req, res) => {
// Service method throws domain-specific errors
const user = await userService.getUser(req.params.id);
res.json(user);
}));
// Error handling middleware understands domain errors
app.use((err, req, res, next) => {
if (err.name === 'UserNotFoundError') {
return res.status(err.statusCode).json({
error: {
message: err.message,
code: err.code
}
});
}
if (err.name === 'UserValidationError') {
return res.status(err.statusCode).json({
error: {
message: err.message,
code: err.code,
details: err.errors
}
});
}
// Handle other errors
next(err);
});
Real-World Example: E-commerce Order Processing
In an e-commerce application, order processing involves multiple asynchronous operations:
// domains/order/service.js
class OrderService {
async createOrder(userId, items) {
// Check if user exists and can place orders
const user = await this.userService.getUser(userId);
if (!user.canPlaceOrders) {
throw new OrderError('User is not allowed to place orders', 403, 'USER_RESTRICTED');
}
// Validate items and check stock
for (const item of items) {
await this.productService.checkAvailability(item.productId, item.quantity);
}
// Calculate total
const total = await this.calculateTotal(items);
// Create order in database
const order = await this.orderRepository.create({
userId,
items,
total,
status: 'pending'
});
// Reserve inventory (can fail if stock changes)
try {
await this.inventoryService.reserveItems(order.id, items);
} catch (error) {
// Rollback order creation
await this.orderRepository.delete(order.id);
throw new OrderError('Failed to reserve inventory', 409, 'INVENTORY_RESERVATION_FAILED');
}
// Return created order
return order;
}
}
// routes/orderRoutes.js
router.post('/orders', catchAsync(async (req, res) => {
const { userId, items } = req.body;
// Validate request
if (!userId || !items || !Array.isArray(items) || items.length === 0) {
throw new ValidationError('Invalid order data');
}
// Delegate to service layer
const order = await orderService.createOrder(userId, items);
// Return success response
res.status(201).json({
message: 'Order created successfully',
order
});
}));
This example demonstrates how a complex business process with multiple potential points of failure can be handled with domain-specific error handling.
Testing Asynchronous Error Handling
Verifying that your error handling works correctly is crucial. Here are approaches to test asynchronous error handling in Express.
Unit Testing Error Handlers
// test/utils/catchAsync.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const catchAsync = require('../../utils/catchAsync');
describe('catchAsync', () => {
it('should pass errors to next function', async () => {
// Arrange
const error = new Error('Test error');
const asyncFn = async () => {
throw error;
};
const req = {};
const res = {
json: sinon.spy(),
send: sinon.spy(),
end: sinon.spy()
};
const next = sinon.spy();
// Act
const middleware = catchAsync(asyncFn);
await middleware(req, res, next);
// Assert
expect(next.calledOnce).to.be.true;
expect(next.calledWith(error)).to.be.true;
});
it('should not call next if response is already sent', async () => {
// Arrange
const asyncFn = async (req, res) => {
// Send response first
res.json({ message: 'Success' });
// Then throw error
throw new Error('Test error');
};
const req = {};
const res = {
json: sinon.spy(),
send: sinon.spy(),
end: sinon.spy()
};
const next = sinon.spy();
// Act
const middleware = catchAsync(asyncFn);
await middleware(req, res, next);
// Assert
expect(res.json.calledOnce).to.be.true;
expect(next.called).to.be.false;
});
});
Integration Testing Async Routes
// test/routes/users.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../app');
const database = require('../../database');
const sinon = require('sinon');
describe('User Routes', () => {
describe('GET /users/:id', () => {
it('should return 404 for non-existent user', async () => {
// Stub database response
sinon.stub(database, 'getUserAsync').resolves(null);
const response = await request(app)
.get('/users/999')
.expect('Content-Type', /json/)
.expect(404);
expect(response.body).to.have.property('error');
expect(response.body.error.message).to.equal('User not found');
// Restore stub
database.getUserAsync.restore();
});
it('should handle database errors properly', async () => {
// Stub database to throw error
sinon.stub(database, 'getUserAsync').rejects(new Error('Database connection error'));
const response = await request(app)
.get('/users/1')
.expect('Content-Type', /json/)
.expect(500);
expect(response.body).to.have.property('error');
expect(response.body.error.message).to.equal('Internal Server Error');
// Restore stub
database.getUserAsync.restore();
});
});
});
Testing Complex Async Flows
// test/services/orderService.test.js
describe('OrderService', () => {
describe('createOrder', () => {
it('should handle inventory reservation failure', async () => {
// Stub dependencies
sinon.stub(userService, 'getUser').resolves({
id: 1,
name: 'Test User',
canPlaceOrders: true
});
sinon.stub(productService, 'checkAvailability').resolves(true);
sinon.stub(orderRepository, 'create').resolves({
id: 123,
userId: 1,
items: [{ productId: 1, quantity: 2 }],
total: 100,
status: 'pending'
});
// Stub inventory service to fail
sinon.stub(inventoryService, 'reserveItems').rejects(
new Error('Not enough stock')
);
// Stub order deletion
sinon.stub(orderRepository, 'delete').resolves(true);
// Act & Assert
try {
await orderService.createOrder(1, [{ productId: 1, quantity: 2 }]);
// Should not reach here
expect.fail('Should have thrown an error');
} catch (error) {
expect(error.code).to.equal('INVENTORY_RESERVATION_FAILED');
expect(orderRepository.delete.calledOnce).to.be.true;
}
// Restore stubs
userService.getUser.restore();
productService.checkAvailability.restore();
orderRepository.create.restore();
inventoryService.reserveItems.restore();
orderRepository.delete.restore();
});
});
});
Testing Best Practices
When testing asynchronous error handling:
- Test both successful flows and error scenarios
- Verify that errors are properly propagated
- Check that appropriate status codes and error messages are returned
- Test edge cases like errors after response is sent
- Use stubs for dependencies to simulate different error conditions
- Test rollback mechanisms for complex operations
Practical Exercise
Build a Robust Async Error Handling System
In this exercise, you'll implement a complete asynchronous error handling system for an Express API.
Requirements
- Create a catchAsync utility function that handles different scenarios
- Implement domain-specific error classes
- Create a central error handler middleware
- Build API routes that use async/await with proper error handling
- Implement testing for your error handling
Steps
- Create the catchAsync.js utility with the enhanced version from this lecture
- Define at least 3 custom error types (e.g., NotFoundError, ValidationError, DatabaseError)
- Implement the central error handler middleware that formats errors consistently
- Create several Express routes using async/await and your error handling system
- Add unit tests to verify that your error handling works correctly
Sample API Endpoints
Implement the following endpoints with proper error handling:
- GET /users/:id - Fetch a user by ID (handle "not found" case)
- POST /users - Create a new user (handle validation errors)
- GET /users/:id/orders - Fetch a user's orders (handle database errors)
- POST /orders - Create an order (complex flow with multiple potential errors)
Testing Scenarios
Test your implementation with these scenarios:
- Requesting a non-existent user
- Creating a user with invalid data
- Simulating a database connection error
- Creating an order with insufficient inventory
Bonus Challenge
Extend your system to include:
- Automatic error logging to a file
- Handling race conditions in async operations
- Implementing rollback mechanisms for failed operations
Summary
Key Takeaways
- Asynchronous errors require special handling in Express applications
- Different async patterns (callbacks, promises, async/await) need different error handling approaches
- The catchAsync utility is a powerful pattern for handling errors in async/await functions
- Domain-driven error handling improves code organization and error specificity
- Proper testing of error scenarios is crucial for reliable applications
- Consider environment-specific error handling for development vs. production
- Advanced patterns can simplify error handling in complex applications
Best Practices
- Always handle errors in async operations
- Use a wrapper function like catchAsync to avoid repetitive try/catch blocks
- Create domain-specific error classes for better error organization
- Test both successful and error paths in your asynchronous code
- Consider using libraries like express-async-errors for simpler code
- Handle "headers already sent" scenarios in your error handling
- Implement global unhandled rejection handlers as a last resort
Further Reading
Next Steps
In our next lecture, we'll explore Error Response Strategies, focusing on how to format error responses for different types of clients, implement error codes, and design a consistent error response system.
Practice Activities
Basic Exercises
- Implement the enhanced catchAsync utility presented in this lecture
- Convert a callback-based API to use promises and proper error handling
- Create a set of domain-specific error classes for a hypothetical application
- Write tests for error handling in async/await functions
- Implement a router wrapper that automatically adds async error handling
Advanced Project
Build an "Async Task Manager" Express API that demonstrates robust error handling:
- Create, read, update, and delete asynchronous tasks
- Implement a task execution system with different possible failure modes
- Handle errors at different levels (service, controller, middleware)
- Implement rollback mechanisms for complex operations
- Add comprehensive error logging and monitoring
- Create a test suite that covers various error scenarios