Introduction to Asynchronous Error Handling
Error handling in asynchronous JavaScript presents unique challenges compared to synchronous code. When operations are performed across multiple execution contexts, frames, or even network boundaries, traditional error handling patterns need to be adapted.
Think of asynchronous error handling like managing a delivery service across a city. In synchronous code, errors are like problems you encounter while personally delivering a package - you immediately know something went wrong. In asynchronous code, errors are like reports from different delivery drivers that might arrive at different times, through different channels, or might not arrive at all if communication breaks down.
The Evolution of Asynchronous Error Handling
JavaScript's approach to asynchronous error handling has evolved significantly over time, with each new pattern improving on the limitations of the previous ones.
The Callback Era
In the early days of asynchronous JavaScript, error handling relied on convention rather than language features. The "error-first callback" pattern became the standard approach:
Error-First Callbacks
function readFile(path, callback) {
fs.readFile(path, (err, data) => {
if (err) {
callback(err);
return;
}
callback(null, data);
});
}
readFile('config.json', (err, data) => {
if (err) {
console.error('Error reading file:', err);
return;
}
try {
const config = JSON.parse(data);
console.log('Config loaded:', config);
} catch (parseError) {
console.error('Error parsing JSON:', parseError);
}
});
Challenges with callbacks:
- Error propagation requires explicit passing through each callback
- Easy to forget error handling in deeply nested callbacks
- No built-in mechanism to catch errors thrown inside callbacks
- Callback hell makes error handling code verbose and difficult to follow
The Promise Revolution
Promises introduced a more structured approach to error handling:
Promise Error Handling
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, (err, data) => {
if (err) {
reject(err);
return;
}
resolve(data);
});
});
}
readFilePromise('config.json')
.then(data => {
const config = JSON.parse(data);
console.log('Config loaded:', config);
})
.catch(err => {
console.error('Operation failed:', err);
});
Improvements with Promises:
- Automatic error propagation through the chain
- Single catch handler can capture errors from multiple operations
- Errors thrown in then handlers are caught by the chain
- More declarative and cleaner code structure
Modern Async/Await Pattern
Async/await combines the readability of synchronous code with the power of promises:
Async/Await Error Handling
async function loadConfig() {
try {
const data = await readFilePromise('config.json');
const config = JSON.parse(data);
console.log('Config loaded:', config);
return config;
} catch (err) {
console.error('Operation failed:', err);
throw err; // Re-throw if needed
}
}
// Usage
loadConfig().catch(err => {
// Handle any errors not caught in loadConfig
console.error('Configuration could not be loaded:', err);
});
Benefits of async/await:
- Familiar try/catch syntax from synchronous code
- Better error stack traces
- Easier to implement conditional logic and loops with error handling
- More readable and maintainable code
Error Types and Classification
A key part of effective error handling is understanding the different types of errors that can occur and handling each appropriately.
Operational vs. Programmer Errors
A useful categorization is to distinguish between operational errors (expected problems that can occur during normal operation) and programmer errors (bugs that should be fixed).
Common Asynchronous Error Types
- Network errors: Failed requests, timeouts, connection issues
- Timeout errors: Operations that take too long
- API errors: Server-side errors with appropriate HTTP status codes
- Validation errors: Invalid input or response data
- Resource errors: Missing files, database connection issues
- Concurrency errors: Race conditions, deadlocks
Creating Custom Error Types
// Base application error class
class AppError extends Error {
constructor(message, options = {}) {
super(message);
this.name = this.constructor.name;
this.status = options.status || 500;
this.code = options.code || 'INTERNAL_ERROR';
this.retryable = options.retryable || false;
this.cause = options.cause; // Store the original error
// Capture stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
// Specific error types
class NetworkError extends AppError {
constructor(message, options = {}) {
super(message, {
status: options.status || 503,
code: options.code || 'NETWORK_ERROR',
retryable: options.retryable !== false, // Network errors are retryable by default
...options
});
}
}
class ValidationError extends AppError {
constructor(message, fields = {}, options = {}) {
super(message, {
status: options.status || 400,
code: options.code || 'VALIDATION_ERROR',
retryable: false, // Validation errors are not retryable
...options
});
this.fields = fields; // Store validation issues by field
}
}
class TimeoutError extends AppError {
constructor(message, options = {}) {
super(message, {
status: options.status || 408,
code: options.code || 'TIMEOUT_ERROR',
retryable: options.retryable !== false, // Timeouts are retryable by default
...options
});
this.timeoutMs = options.timeoutMs;
}
}
// Usage example
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
if (response.status === 404) {
throw new ValidationError(
`User with ID ${userId} not found`,
{ userId: 'not found' }
);
}
if (response.status >= 500) {
throw new NetworkError(
`Server error while fetching user ${userId}`,
{ status: response.status }
);
}
throw new AppError(
`Failed to fetch user ${userId}`,
{ status: response.status }
);
}
return await response.json();
} catch (error) {
if (error instanceof AppError) {
// Our custom error, just rethrow it
throw error;
}
// Handle native fetch errors
if (error.name === 'AbortError') {
throw new TimeoutError(
`Request for user ${userId} timed out`,
{ timeoutMs: 5000, cause: error }
);
}
// Unexpected error
throw new AppError(
`Unexpected error fetching user ${userId}`,
{ cause: error }
);
}
}
Strategies for Handling Asynchronous Errors
Let's explore practical strategies for dealing with different kinds of asynchronous errors.
Strategy 1: Retry with Exponential Backoff
For transient errors like network issues, retrying the operation can often succeed. Exponential backoff increases the delay between retries to avoid overwhelming the system.
Retry Function with Exponential Backoff
/**
* Retries an async function with exponential backoff
* @param {Function} fn - The async function to retry
* @param {Object} options - Configuration options
* @returns {Promise} - The result of the function
*/
async function retry(fn, options = {}) {
const maxRetries = options.maxRetries || 3;
const initialDelay = options.initialDelay || 1000; // 1 second
const backoffFactor = options.backoffFactor || 2;
const maxDelay = options.maxDelay || 30000; // 30 seconds
const shouldRetry = options.shouldRetry || (error => error.retryable !== false);
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// Attempt the operation
return await fn(attempt);
} catch (error) {
lastError = error;
// Check if we should retry
if (attempt >= maxRetries || !shouldRetry(error)) {
break;
}
// Calculate delay with jitter to avoid thundering herd problem
const delay = Math.min(
maxDelay,
initialDelay * Math.pow(backoffFactor, attempt) * (0.8 + Math.random() * 0.4)
);
console.log(`Retrying after ${delay}ms (attempt ${attempt + 1}/${maxRetries})...`);
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// All retries failed
throw lastError;
}
// Usage example
async function fetchWithRetry(url) {
return retry(
async (attempt) => {
console.log(`Attempt ${attempt + 1} to fetch ${url}`);
const response = await fetch(url, { timeout: 3000 });
if (!response.ok) {
const error = new Error(`HTTP error ${response.status}`);
error.status = response.status;
error.retryable = response.status >= 500; // Only retry server errors
throw error;
}
return response.json();
},
{
maxRetries: 5,
initialDelay: 1000,
shouldRetry: error =>
error.retryable === true || // Explicit retryable flag
(error.status >= 500 && error.status < 600) || // Server errors
error.message.includes('timeout') || // Timeout errors
error.message.includes('network') // Network errors
}
);
}
Real-world application: Use retry patterns for:
- API calls that might experience temporary outages
- Database operations during peak load
- IoT device connections with unstable networks
- Distributed systems with network partitions
Strategy 2: Circuit Breaker Pattern
The circuit breaker pattern prevents cascading failures by "tripping" after a threshold of failures and allowing limited "testing" requests to see if the system has recovered.
Circuit Breaker Implementation
class CircuitBreaker {
constructor(options = {}) {
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 30000; // 30 seconds
this.halfOpenSuccess = options.halfOpenSuccess || 1;
this.excludeErrors = options.excludeErrors || [];
this.state = 'CLOSED';
this.failureCount = 0;
this.successCount = 0;
this.nextAttempt = Date.now();
this.lastError = null;
}
async execute(fn) {
// If circuit is open, check if we've waited long enough to try again
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error(`Circuit breaker is open: ${this.lastError.message}`);
}
// Move to half-open state to test the waters
this.state = 'HALF_OPEN';
this.successCount = 0;
}
try {
const result = await fn();
this._handleSuccess();
return result;
} catch (error) {
return this._handleError(error);
}
}
_handleSuccess() {
if (this.state === 'HALF_OPEN') {
this.successCount++;
if (this.successCount >= this.halfOpenSuccess) {
// System seems to be working again
this.state = 'CLOSED';
this.failureCount = 0;
}
} else if (this.state === 'CLOSED') {
// Reset failure count after a successful operation
this.failureCount = 0;
}
}
_handleError(error) {
// Certain errors can be excluded from tripping the circuit
if (this.excludeErrors.some(type => error instanceof type)) {
throw error;
}
this.lastError = error;
if (this.state === 'HALF_OPEN') {
// Failed while testing the waters, back to open state
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.resetTimeout;
} else if (this.state === 'CLOSED') {
this.failureCount++;
if (this.failureCount >= this.failureThreshold) {
// Too many failures, open the circuit
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.resetTimeout;
}
}
throw error;
}
// Reset the circuit breaker to closed state
reset() {
this.state = 'CLOSED';
this.failureCount = 0;
this.successCount = 0;
this.nextAttempt = Date.now();
}
}
// Usage example: protect database access
class DatabaseService {
constructor() {
this.circuitBreaker = new CircuitBreaker({
failureThreshold: 3,
resetTimeout: 10000, // 10 seconds
excludeErrors: [ValidationError] // Don't trip on validation errors
});
}
async query(sql, params) {
return this.circuitBreaker.execute(async () => {
// Database operation here
return db.query(sql, params);
});
}
}
// Example with API calls
class ApiService {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.breakers = {};
}
// Get or create circuit breaker for an endpoint
getBreaker(endpoint) {
if (!this.breakers[endpoint]) {
this.breakers[endpoint] = new CircuitBreaker();
}
return this.breakers[endpoint];
}
async fetch(endpoint, options = {}) {
const breaker = this.getBreaker(endpoint);
return breaker.execute(async () => {
const response = await fetch(`${this.baseUrl}/${endpoint}`, options);
if (!response.ok) {
const error = new Error(`API error: ${response.status}`);
error.status = response.status;
throw error;
}
return response.json();
});
}
}
Real-world application: Circuit breakers are valuable for:
- Microservices architectures to prevent cascading failures
- External API integrations that might experience outages
- Database connections during high load
- Payment processing systems to prevent repeated failed charges
Strategy 3: Timeouts and Cancellation
Never let async operations hang indefinitely. Implement timeouts and provide ways to cancel operations.
Implementing Timeouts with AbortController
/**
* Adds a timeout to any promise
* @param {Promise} promise - The promise to add timeout to
* @param {number} timeoutMs - Timeout in milliseconds
* @param {string} message - Error message if timeout occurs
* @returns {Promise} - Promise with timeout
*/
function withTimeout(promise, timeoutMs, message = 'Operation timed out') {
// Create an abort controller for the fetch operation
const controller = new AbortController();
const signal = controller.signal;
// Create a timeout promise that rejects after specified time
const timeoutPromise = new Promise((_, reject) => {
const timeoutId = setTimeout(() => {
controller.abort();
reject(new TimeoutError(message, { timeoutMs }));
}, timeoutMs);
// Cleanup timeout if the main promise resolves/rejects
signal.addEventListener('abort', () => clearTimeout(timeoutId));
});
// Return race between the main promise and timeout
return Promise.race([
promise,
timeoutPromise
]);
}
// Example usage with fetch
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController();
// Add the abort signal to fetch options
const fetchOptions = {
...options,
signal: controller.signal
};
try {
return await withTimeout(
fetch(url, fetchOptions),
timeoutMs,
`Fetch to ${url} timed out after ${timeoutMs}ms`
);
} catch (error) {
if (error.name === 'AbortError' || error instanceof TimeoutError) {
// Handle timeout specific logic
console.log(`Request to ${url} timed out`);
}
throw error;
}
}
// Function that can be cancelled externally
function createCancellableOperation() {
const controller = new AbortController();
const signal = controller.signal;
const operation = async () => {
// Check for cancellation at key points
const checkCancellation = () => {
if (signal.aborted) {
throw new Error('Operation was cancelled');
}
};
try {
// First step
checkCancellation();
await step1(signal);
// Second step
checkCancellation();
await step2(signal);
// Third step
checkCancellation();
return await step3(signal);
} catch (error) {
if (signal.aborted) {
console.log('Operation cancelled:', signal.reason);
throw new Error('Operation was cancelled: ' + signal.reason);
}
throw error;
}
};
// Return both the operation promise and ways to control it
return {
execute: operation,
cancel: (reason = 'User cancelled') => controller.abort(reason),
signal
};
}
// Usage example
async function loadUserData(userId) {
const operation = createCancellableOperation();
// Allow external cancellation
document.getElementById('cancelButton').addEventListener('click', () => {
operation.cancel('User clicked cancel');
});
try {
return await withTimeout(
operation.execute(),
10000,
'User data loading timed out'
);
} catch (error) {
console.error('Failed to load user data:', error);
throw error;
}
}
Real-world application: Timeouts and cancellation are essential for:
- User-facing operations that should remain responsive
- Background data syncing that shouldn't drain battery
- Long-running calculations that might be superseded by new inputs
- Multi-step workflows where a user might change their mind
Strategy 4: Graceful Degradation
When errors occur, provide fallback behavior instead of completely failing.
Implementing Fallbacks and Graceful Degradation
/**
* Attempts to execute a function, falling back to alternatives if it fails
* @param {Function[]} fns - Array of functions to try in order
* @returns {Promise} - Result from the first successful function
*/
async function withFallbacks(fns) {
const errors = [];
for (let i = 0; i < fns.length; i++) {
try {
return await fns[i]();
} catch (error) {
errors.push(error);
console.warn(`Fallback ${i + 1}/${fns.length} failed:`, error);
// Continue to next fallback
}
}
// If we get here, all fallbacks failed
const error = new Error('All fallbacks failed');
error.errors = errors; // Attach all errors for debugging
throw error;
}
// Example: Loading user profile with fallbacks
async function loadUserProfile(userId) {
return withFallbacks([
// Primary: Get from API with full details
() => fetch(`/api/users/${userId}/profile`).then(r => r.json()),
// Fallback 1: Get from cache with full details
() => cacheStorage.match(`/api/users/${userId}/profile`).then(r => r.json()),
// Fallback 2: Get minimal profile from API
() => fetch(`/api/users/${userId}/basic-profile`).then(r => r.json()),
// Fallback 3: Get from local storage (might be outdated)
() => JSON.parse(localStorage.getItem(`user_${userId}_profile`)),
// Last resort: Return default profile
() => ({
id: userId,
name: 'Unknown User',
avatar: '/images/default-avatar.png',
isPartialData: true
})
]);
}
// Example: Feature with degraded functionality
class RecommendationEngine {
async getRecommendations(userId) {
try {
// Try to get personalized recommendations
return await this.getPersonalizedRecommendations(userId);
} catch (error) {
console.warn('Personalized recommendations failed:', error);
try {
// Fall back to category-based recommendations
return await this.getCategoryRecommendations(userId);
} catch (fallbackError) {
console.warn('Category recommendations failed:', fallbackError);
// Last resort: return popular items
return this.getPopularItems();
}
}
}
async getPersonalizedRecommendations(userId) {
// Complex ML-based recommendations
// ...
}
async getCategoryRecommendations(userId) {
// Simpler category-based recommendations
// ...
}
getPopularItems() {
// Just return a static list of popular items
return [
{ id: 1, name: 'Popular Item 1' },
{ id: 2, name: 'Popular Item 2' },
// ...
];
}
}
Real-world application: Graceful degradation is valuable for:
- Progressive web apps that work offline
- Recommendation systems that can fall back to simpler algorithms
- Media players that can switch to lower quality when bandwidth is limited
- Applications that can function with partial data
Global Error Handling
In complex applications, it's important to have a global error handling strategy for uncaught asynchronous errors.
Global Unhandled Promise Rejection Handler
// Global handler for unhandled promise rejections
window.addEventListener('unhandledrejection', event => {
const error = event.reason;
console.error('Unhandled promise rejection:', error);
// Log to monitoring service
logErrorToMonitoring(error);
// Show user-friendly error message if appropriate
if (shouldShowUserError(error)) {
showErrorNotification(
'Something went wrong',
error.userMessage || 'Please try again or contact support.'
);
}
// Prevent the default browser behavior (console error)
event.preventDefault();
});
// Error boundary for async React components
class AsyncErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log to monitoring service
logErrorToMonitoring(error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback ? (
this.props.fallback(this.state.error)
) : (
<div className="error-boundary">
<h2>Something went wrong</h2>
<p>Please try again or contact support.</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
// Advanced error monitoring integration
function logErrorToMonitoring(error, additionalInfo = {}) {
// Include important context with the error
const errorContext = {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
userId: currentUser?.id,
...additionalInfo
};
// Extract useful information from error
const errorData = {
message: error.message,
stack: error.stack,
name: error.name,
code: error.code,
status: error.status,
// Capture nested error causes
cause: error.cause ? {
message: error.cause.message,
stack: error.cause.stack,
name: error.cause.name
} : null
};
// Send to monitoring service (e.g., Sentry, LogRocket, etc.)
monitoringService.captureException(error, {
extra: {
...errorContext,
errorData
}
});
// Optional: also log to server-side error tracking
fetch('/api/log-client-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: errorData, context: errorContext }),
// Use keepalive to ensure the request completes even if page unloads
keepalive: true
}).catch(e => {
// Just ignore errors from error logging itself
console.error('Failed to send error log:', e);
});
}
Error Handling in Different Environments
Error handling strategies differ slightly depending on the environment:
Best Practice: Always implement global error handlers as a safety net, but don't rely on them as your primary error handling strategy. Properly handle errors at the source whenever possible.
Debugging Asynchronous Errors
Asynchronous errors can be challenging to debug due to broken stack traces and timing issues. Here are some techniques to improve debugging:
Enhanced Error Capture Techniques
// Capture stack trace at creation time
function createPromiseWithEnhancedStack() {
// Capture stack trace at promise creation time
const creationStack = new Error().stack;
return new Promise((resolve, reject) => {
// Store the creation stack to add to any future rejections
const enhancedReject = error => {
if (error instanceof Error) {
error.stack = `${error.stack}\n\nPromise created at:\n${creationStack.split('\n').slice(1).join('\n')}`;
}
reject(error);
};
// Your async logic here
someAsyncOperation()
.then(resolve)
.catch(enhancedReject);
});
}
// Helper to add creation stacks to all promises in development
if (process.env.NODE_ENV === 'development') {
const originalPromise = Promise;
globalThis.Promise = function PromiseWithStack(...args) {
const creationStack = new Error().stack;
const promise = new originalPromise(...args);
const enhancedThen = promise.then;
promise.then = function (onFulfilled, onRejected) {
const newOnRejected = typeof onRejected === 'function'
? function (error) {
try {
return onRejected(error);
} catch (e) {
e.stack = `${e.stack}\n\nPromise created at:\n${creationStack.split('\n').slice(1).join('\n')}`;
throw e;
}
}
: error => {
if (error instanceof Error) {
error.stack = `${error.stack}\n\nPromise created at:\n${creationStack.split('\n').slice(1).join('\n')}`;
}
throw error;
};
return enhancedThen.call(promise, onFulfilled, newOnRejected);
};
return promise;
};
// Copy prototype and properties
globalThis.Promise.prototype = originalPromise.prototype;
Object.setPrototypeOf(globalThis.Promise, originalPromise);
}
// Logger with async correlation IDs
class AsyncLogger {
constructor() {
this.requestId = this._generateId();
this.contextStack = [];
}
_generateId() {
return Math.random().toString(36).substring(2, 15);
}
pushContext(context) {
const id = this._generateId();
this.contextStack.push({ id, ...context });
return id;
}
popContext() {
return this.contextStack.pop();
}
log(level, message, data = {}) {
const currentContext = this.contextStack.length > 0
? this.contextStack[this.contextStack.length - 1]
: {};
console[level](`[${this.requestId}:${currentContext.id || 'root'}] ${message}`, {
...data,
requestId: this.requestId,
context: currentContext
});
}
async withContext(context, fn) {
const contextId = this.pushContext(context);
try {
this.log('info', `Starting: ${context.name || 'unnamed operation'}`);
const result = await fn();
this.log('info', `Completed: ${context.name || 'unnamed operation'}`);
return result;
} catch (error) {
this.log('error', `Failed: ${context.name || 'unnamed operation'}`, { error });
throw error;
} finally {
this.popContext();
}
}
// Convenience methods
info(message, data) { this.log('info', message, data); }
warn(message, data) { this.log('warn', message, data); }
error(message, data) { this.log('error', message, data); }
debug(message, data) { this.log('debug', message, data); }
}
// Usage example
const logger = new AsyncLogger();
async function processOrder(orderId) {
return logger.withContext({ name: 'processOrder', orderId }, async () => {
logger.info('Processing order');
// Verify payment
await logger.withContext({ name: 'verifyPayment', orderId }, async () => {
logger.debug('Checking payment status');
// Payment verification logic
});
// Update inventory
await logger.withContext({ name: 'updateInventory', orderId }, async () => {
logger.debug('Updating inventory');
// Inventory update logic
});
// Send confirmation
await logger.withContext({ name: 'sendConfirmation', orderId }, async () => {
logger.debug('Sending confirmation');
// Send email logic
});
logger.info('Order processing complete');
});
}
Modern Debugging Tools
Take advantage of modern debugging tools designed for asynchronous code:
- Chrome DevTools Async Stack Traces: Follow asynchronous call chains
- Source Maps: Map transpiled code back to original source
- Error monitoring platforms: Tools like Sentry, LogRocket, or New Relic
- Distributed tracing: Tools like OpenTelemetry for tracing across services
Tip: During development, consider using console.trace() at key points in your asynchronous code to log stack traces. This can help identify where asynchronous operations are being initiated.
Best Practices and Guidelines
Let's summarize the key best practices for robust asynchronous error handling:
- Be specific about error types: Create custom error classes for different error categories
- Fail fast: Validate inputs early to avoid wasting resources on operations that will fail
- Include context in errors: Add relevant information that helps diagnose the problem
- Implement appropriate retry strategies: Use exponential backoff for transient errors
- Set timeouts for all async operations: Never let operations hang indefinitely
- Provide cancellation mechanisms: Allow long-running operations to be interrupted
- Implement graceful degradation: Have fallback strategies when primary approaches fail
- Use circuit breakers: Protect systems from cascading failures
- Centralize error handling logic: Avoid duplicating error handling code
- Log errors appropriately: Include context, but be mindful of sensitive information
- Provide user-friendly error messages: Translate technical errors into actionable information
- Implement global error handling: Catch unhandled rejections as a safety net
Complete Error Handling Example: Data Fetching Module
// errors.js - Error types
class AppError extends Error {
constructor(message, options = {}) {
super(message);
this.name = this.constructor.name;
this.status = options.status || 500;
this.code = options.code || 'INTERNAL_ERROR';
this.retryable = options.retryable ?? false;
this.userMessage = options.userMessage || 'Something went wrong.';
this.cause = options.cause;
Error.captureStackTrace?.(this, this.constructor);
}
}
class NetworkError extends AppError {
constructor(message, options = {}) {
super(message, {
code: 'NETWORK_ERROR',
status: 503,
retryable: true,
userMessage: 'There was a network issue. Please check your connection.',
...options
});
}
}
class TimeoutError extends AppError {
constructor(message, options = {}) {
super(message, {
code: 'TIMEOUT_ERROR',
status: 408,
retryable: true,
userMessage: 'The operation timed out. Please try again.',
...options
});
}
}
class NotFoundError extends AppError {
constructor(message, options = {}) {
super(message, {
code: 'NOT_FOUND',
status: 404,
retryable: false,
userMessage: 'The requested resource was not found.',
...options
});
}
}
// utils.js - Utility functions for error handling
async function retry(fn, options = {}) {
const maxRetries = options.maxRetries || 3;
const initialDelay = options.initialDelay || 1000;
const backoffFactor = options.backoffFactor || 2;
const maxDelay = options.maxDelay || 30000;
const retryableErrors = options.retryableErrors || [NetworkError, TimeoutError];
const shouldRetry = options.shouldRetry || (error => {
return error.retryable === true ||
retryableErrors.some(ErrorType => error instanceof ErrorType);
});
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn(attempt);
} catch (error) {
lastError = error;
// Don't retry if this error isn't retryable
if (attempt >= maxRetries || !shouldRetry(error)) {
break;
}
// Calculate delay with jitter
const delay = Math.min(
maxDelay,
initialDelay * Math.pow(backoffFactor, attempt) * (0.8 + Math.random() * 0.4)
);
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
function withTimeout(promise, timeoutMs, message = 'Operation timed out') {
// Create a timeout promise that rejects after specified time
const timeoutPromise = new Promise((_, reject) => {
const id = setTimeout(() => {
reject(new TimeoutError(message, { timeoutMs }));
}, timeoutMs);
// Ensure the timeout is cleared if the main promise resolves
promise.finally(() => clearTimeout(id)).catch(() => {});
});
// Race the main promise against the timeout
return Promise.race([promise, timeoutPromise]);
}
// Fetch with error handling, retries, timeouts, and circuit breaker
class ApiClient {
constructor(baseUrl, options = {}) {
this.baseUrl = baseUrl;
this.options = {
timeout: 10000,
retries: 3,
...options
};
// Set up circuit breakers for different endpoints
this.breakers = {};
}
getCircuitBreaker(endpoint) {
if (!this.breakers[endpoint]) {
this.breakers[endpoint] = new CircuitBreaker({
failureThreshold: 5,
resetTimeout: 30000
});
}
return this.breakers[endpoint];
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}/${endpoint}`;
const fetchOptions = {
headers: {
'Content-Type': 'application/json',
...this.options.headers,
...options.headers
},
...options
};
// Get the circuit breaker for this endpoint
const breaker = this.getCircuitBreaker(endpoint);
// Execute with circuit breaker protection
return breaker.execute(async () => {
// Retry with exponential backoff
return retry(
async (attempt) => {
// If not the first attempt, add retry header for tracking
if (attempt > 0) {
fetchOptions.headers['X-Retry-Count'] = attempt;
}
try {
// Execute with timeout
const response = await withTimeout(
fetch(url, fetchOptions),
options.timeout || this.options.timeout,
`Request to ${url} timed out`
);
// Check for HTTP error status
if (!response.ok) {
let errorMessage;
let errorData;
// Try to extract error details from response
try {
errorData = await response.json();
errorMessage = errorData.message || errorData.error || `HTTP error ${response.status}`;
} catch (e) {
// If we can't parse JSON, use the status text
errorMessage = `HTTP error ${response.status} ${response.statusText}`;
}
// Create appropriate error type based on status
switch (response.status) {
case 404:
throw new NotFoundError(errorMessage, {
status: response.status,
data: errorData
});
case 400:
case 422:
throw new AppError(errorMessage, {
code: 'VALIDATION_ERROR',
status: response.status,
retryable: false,
data: errorData
});
case 401:
case 403:
throw new AppError(errorMessage, {
code: 'AUTHORIZATION_ERROR',
status: response.status,
retryable: false,
data: errorData,
userMessage: 'You do not have permission to access this resource.'
});
case 429:
throw new AppError(errorMessage, {
code: 'RATE_LIMIT_ERROR',
status: response.status,
retryable: true,
data: errorData,
userMessage: 'Too many requests. Please try again later.'
});
case 500:
case 502:
case 503:
case 504:
throw new NetworkError(errorMessage, {
status: response.status,
data: errorData
});
default:
throw new AppError(errorMessage, {
status: response.status,
retryable: response.status >= 500,
data: errorData
});
}
}
// Process response based on content type
const contentType = response.headers.get('Content-Type') || '';
if (contentType.includes('application/json')) {
return await response.json();
} else if (contentType.includes('text/')) {
return await response.text();
} else {
return await response.blob();
}
} catch (error) {
// Convert native fetch errors to our custom error types
if (error.name === 'AbortError' && !error.message.includes('timeout')) {
throw new AppError('Request was aborted', {
code: 'ABORTED',
retryable: false,
cause: error,
userMessage: 'The operation was cancelled.'
});
} else if (error.message.includes('Failed to fetch') ||
error.message.includes('Network request failed')) {
throw new NetworkError('Network request failed', {
cause: error
});
}
// Rethrow custom errors and unhandled errors
throw error;
}
},
{
maxRetries: options.retries || this.options.retries,
shouldRetry: error => error.retryable !== false
}
);
});
}
// Convenience methods for common HTTP methods
async get(endpoint, options = {}) {
return this.request(endpoint, {
method: 'GET',
...options
});
}
async post(endpoint, data, options = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data),
...options
});
}
async put(endpoint, data, options = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
...options
});
}
async delete(endpoint, options = {}) {
return this.request(endpoint, {
method: 'DELETE',
...options
});
}
}
// Usage example
const api = new ApiClient('https://api.example.com', {
headers: {
'Authorization': 'Bearer token123'
},
timeout: 5000,
retries: 2
});
// In an async function
async function loadUser(userId) {
try {
return await api.get(`users/${userId}`);
} catch (error) {
if (error instanceof NotFoundError) {
console.log(`User ${userId} not found`);
return null;
}
// Let other errors propagate
throw error;
}
}
Practice Exercises
Exercise 1: Error Handling Framework
Build a small library of error handling utilities including:
- Custom error classes for different error types
- A retry function with exponential backoff
- A timeout wrapper for promises
- A circuit breaker implementation
Then use these tools to create a robust API client that handles errors gracefully.
Exercise 2: Fault Tolerance Testing
Create a testing framework that:
- Simulates different types of failures (network errors, timeouts, server errors)
- Tests your error handling strategies under various failure scenarios
- Verifies that your application degrades gracefully
- Ensures errors are properly logged and reported
Exercise 3: Async Workflow with Error Recovery
Implement a multi-step asynchronous workflow (e.g., a checkout process) that:
- Handles errors at each step appropriately
- Implements retry logic for transient failures
- Provides partial success scenarios when possible
- Logs errors for debugging and monitoring
- Gives user-friendly error messages
Summary
Robust error handling is a critical aspect of asynchronous JavaScript programming. In this lecture, we've explored:
- The evolution of error handling patterns from callbacks to promises to async/await
- Different types of asynchronous errors and how to handle each appropriately
- Advanced strategies like retry with exponential backoff, circuit breakers, timeouts, and graceful degradation
- Techniques for debugging asynchronous errors
- Best practices for comprehensive error handling in real-world applications
By implementing these strategies, you can build more resilient applications that gracefully handle failures, provide better user experiences, and are easier to debug and maintain.