DEV Community

Ahmed Moussa
Ahmed Moussa

Posted on

Create content about 'programming' on Dev.to (avg 104 engagement)


html





Building Resilient Applications: A Developer's Guide to Error Handling and Recovery Patterns



Building Resilient Applications: A Developer's Guide to Error Handling and Recovery Patterns

In the world of software development, one truth remains constant: things will go wrong. Networks fail, databases become unavailable, APIs return unexpected responses, and users input data in ways we never anticipated. The difference between a solid, production-ready application and a fragile prototype often lies in how gracefully it handles these inevitable failures.

Today, we'll explore complete error handling strategies and recovery patterns that will help you build more resilient applications. Whether you're a junior developer looking to improve your error handling skills or a seasoned programmer seeking to refine your approach, this guide will provide practical techniques you can implement immediately.

Understanding the Error Landscape

Before diving into solutions, it's crucial to understand the types of errors we encounter in modern applications. Errors generally fall into several categories:

Synchronous Errors
These are traditional errors that occur during normal code execution, such as null pointer exceptions, type errors, or validation failures.


function calculateDiscount(price, discountPercent) {
if (price === null || price === undefined) {
throw new Error('Price cannot be null or undefined');
}

if (discountPercent  100) {
throw new Error('Discount percent must be between 0 and 100');
}

return price * (discountPercent / 100);
}

try {
const discount = calculateDiscount(null, 15);
} catch (error) {
console.error('Calculation failed:', error.message);
// Handle the error appropriately
}


Asynchronous Errors
These occur in promises, async/await operations, or callback-based code. They require special handling patterns to prevent unhandled rejections.


async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const userData = await response.json();
return userData;
} catch (error) {
if (error instanceof TypeError) {
// Network error
throw new Error('Network connection failed');
} else {
// Re-throw other errors
throw error;
}
}
}


System-Level Errors
These include database connection failures, file system errors, or external service unavailability. They often require retry mechanisms and graceful degradation.

The Circuit Breaker Pattern

One of the most powerful patterns for handling external service failures is the Circuit Breaker pattern. It prevents your application from repeatedly calling a failing service, giving it time to recover while providing immediate feedback to users.


class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.threshold = threshold;
this.timeout = timeout;
this.failureCount = 0;
this.lastFailureTime = null;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
}

async call(fn) {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.timeout) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker is OPEN');
}
}

try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}

onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}

onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();

if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
}
}
}

// Usage example
const breaker = new CircuitBreaker(3, 30000);

async function callExternalAPI() {
try {
return await breaker.call(async () => {
const response = await fetch('/api/external-service');
if (!response.ok) throw new Error('API call failed');
return response.json();
});
} catch (error) {
console.log('Service unavailable, using cached data');
return getCachedData();
}
}


Retry Mechanisms with Exponential Backoff

Not all failures are permanent. Network hiccups, temporary database locks, or brief service overloads can often be resolved by simply trying again. However, naive retry mechanisms can make problems worse by overwhelming already struggling services.

Exponential backoff with jitter provides an elegant solution:


class RetryManager {
constructor(maxRetries = 3, baseDelay = 1000, maxDelay = 30000) {
this.maxRetries = maxRetries;
this.baseDelay = baseDelay;
this.maxDelay = maxDelay;
}

async executeWithRetry(fn, retryableErrors = []) {
let lastError;

for (let attempt = 0; attempt  error instanceof errorType);

if (!isRetryable || attempt === this.maxRetries) {
throw error;
}

const delay = this.calculateDelay(attempt);
console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`);
await this.sleep(delay);
}
}

throw lastError;
}

calculateDelay(attempt) {
// Exponential backoff with jitter
const exponentialDelay = Math.min(
this.baseDelay * Math.pow(2, attempt),
this.maxDelay
);

// Add jitter (±25% randomization)
const jitter = exponentialDelay * 0.25 * (Math.random() - 0.5);
return Math.round(exponentialDelay + jitter);
}

sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

// Usage example
const retryManager = new RetryManager(3, 1000, 10000);

async function saveUserData(userData) {
return await retryManager.executeWithRetry(
async () => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});

if (response.status >= 500) {
throw new Error(`Server error: ${response.status}`);
}

if (!response.ok) {
throw new Error(`Client error: ${response.status}`);
}

return response.json();
},
[Error] // Retry on any Error
);
}


Graceful Degradation Strategies

Sometimes the best error handling strategy is to provide a reduced but functional experience rather than complete failure. This approach, known as graceful degradation, keeps your application usable even when some components fail.


class FeatureManager {
constructor() {
this.features = new Map();
this.fallbacks = new Map();
}

registerFeature(name, implementation, fallback = null) {
this.features.set(name, implementation);
if (fallback) {
this.fallbacks.set(name, fallback);
}
}

async executeFeature(name, ...args) {
const feature = this.features.get(name);
if (!feature) {
throw new Error(`Feature '${name}' not found`);
}

try {
return await feature(...args);
} catch (error) {
console.warn(`Feature '${name}' failed:`, error.message);

const fallback = this.fallbacks.get(name);
if (fallback) {
console.log(`Using fallback for feature '${name}'`);
return await fallback(...args);
}

throw error;
}
}
}

// Example usage
const featureManager = new FeatureManager();

// Register a feature with AI-powered recommendations
featureManager.registerFeature(
'recommendations',
async (userId) => {
const response = await fetch(`/api/ai-recommendations/${userId}`);
if (!response.ok) throw new Error('AI service unavailable');
return response.json();
},
// Fallback to simple popular items
async (userId) => {
const response = await fetch('/api/popular-items');
return response.json();
}
);

// Register search with fallback
featureManager.registerFeature(
'search',
async (query) => {
const response = await fetch(`/api/elasticsearch/search?q=${query}`);
if (!response.ok) throw new Error('Search service unavailable');
return response.json();
},
// Fallback to basic database search
async (query) => {
const response = await fetch(`/api/basic-search?q=${query}`);
return response.json();
}
);


Error Boundaries in React Applications

For React developers, Error Boundaries provide a way to catch JavaScript errors anywhere in the component tree and display a fallback UI instead of crashing the entire application.


import React from 'react';

class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}

static getDerivedStateFromError(error) {
return { hasError: true };
}

componentDidCatch(error, errorInfo) {
this.setState({
error: error,
errorInfo: errorInfo
});

// Log error to monitoring service
this.logErrorToService(error, errorInfo);
}

logErrorToService(error, errorInfo) {
// Send to error tracking service like [Sentry](https://sentry.io/), LogRocket, etc. Console.error('Error caught by boundary:', error, errorInfo);
}

render() {
if (this.state.hasError) {
return (

Something went wrong
We're sorry for the inconvenience. Please try refreshing the page.
{process.env.NODE_ENV === 'development' && (

Error Details (Development Only)
{this.state.error && this.state.error.toString()}

{this.state.errorInfo.componentStack}

)}
 window.location.reload()}>
Refresh Page


);
}

return this.props.children;
}
}

// Usage
function App() {
return (





);
}


Practical Tips for Better Error Handling

1. Create Custom Error Classes
Custom error classes make it easier to handle different types of errors appropriately and provide better debugging information.


class ValidationError extends Error {
constructor(field, message) {
super(`Validation failed for ${field}: ${message}`);
this.name = 'ValidationError';
this.field = field;
}
}

class NetworkError extends Error {
constructor(url, status) {
super(`Network request failed: ${status} for ${url}`);
this.name = 'NetworkError';
this.url = url;
this.status = status;
}
}

class BusinessLogicError extends Error {
constructor(message, code) {
super(message);
this.name = 'BusinessLogicError';
this.code = code;
}
}


2. Implement complete Logging
Good logging is essential for debugging production issues. Include context, timestamps, and structured data.


class Logger {
static log(level, message, context = {}) {
const logEntry = {
timestamp: new Date().toISOString(),
level,
message,
context,
stack: new Error().stack
};

console[level](JSON.stringify(logEntry));

// Send to external logging service in production
if (process.env.NODE_ENV === 'production') {
this.sendToLoggingService(logEntry);
}
}

static error(message, context) {
this.log('error', message, context);
}

static warn(message, context) {
this.log('warn', message, context);
}

static info(message, context) {
this.log('info', message, context);
}
}


3. Validate Input Early and Often
Catch errors as close to their source as possible. Input validation should happen at multiple layers.


function validateUserInput(userData) {
const errors = [];

if (!userData.email || !isValidEmail(userData.email)) {
errors.push(new ValidationError('email', 'Valid email is required'));
}

if (!userData.password || userData.password.length  0) {
throw new ValidationError('input', `Multiple validation errors: ${errors.map(e => e.message).join(', ')}`);
}

return true;
}

function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}


4. Use Health Checks and Monitoring
Implement health check endpoints to monitor the status of your application and its dependencies.


class Health
Enter fullscreen mode Exit fullscreen mode

Top comments (0)