Intermediate
20 min
Full Guide
API Error Handling
Handle API errors gracefully with proper error codes, retries, and fallback strategies
Why Error Handling Matters
APIs can fail for many reasons—network issues, server errors, rate limits, invalid data, or authentication problems. Proper error handling ensures your application remains stable, provides helpful feedback to users, and can recover gracefully from failures.
A well-designed error handling strategy improves user experience and makes debugging much easier.
Types of API Errors
❌ Network Errors
- • No internet connection
- • DNS resolution failed
- • Request timeout
- • CORS issues
⚠️ Client Errors (4xx)
- • 400 - Bad request data
- • 401 - Not authenticated
- • 403 - Not authorized
- • 404 - Resource not found
- • 429 - Rate limited
🔥 Server Errors (5xx)
- • 500 - Internal server error
- • 502 - Bad gateway
- • 503 - Service unavailable
- • 504 - Gateway timeout
📦 Data Errors
- • Invalid JSON response
- • Unexpected data format
- • Missing required fields
- • Type mismatches
Basic Error Handling Pattern
// Comprehensive fetch error handling
async function fetchWithErrorHandling(url, options = {}) {
try {
const response = await fetch(url, options);
// Network request succeeded, but we need to check HTTP status
if (!response.ok) {
// Try to parse error details from response
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { message: response.statusText };
}
// Create detailed error
const error = new Error(errorData.message || 'Request failed');
error.status = response.status;
error.statusText = response.statusText;
error.data = errorData;
throw error;
}
// Parse and return successful response
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await response.json();
}
return await response.text();
} catch (error) {
// Handle different error types
if (error.name === 'TypeError') {
// Network error (no internet, CORS, etc.)
throw new NetworkError('Unable to connect to server');
}
if (error.name === 'AbortError') {
throw new TimeoutError('Request was cancelled');
}
// Re-throw HTTP errors
throw error;
}
}
// Custom error classes
class ApiError extends Error {
constructor(message, status, data) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
class NetworkError extends ApiError {
constructor(message) {
super(message, 0);
this.name = 'NetworkError';
}
}
class TimeoutError extends ApiError {
constructor(message) {
super(message, 0);
this.name = 'TimeoutError';
}
}
class ValidationError extends ApiError {
constructor(message, errors) {
super(message, 422);
this.name = 'ValidationError';
this.errors = errors;
}
}
Status Code Specific Handling
// Handle errors based on status code
async function handleApiError(error) {
switch (error.status) {
case 400:
// Bad request - show validation errors
showValidationErrors(error.data.errors);
break;
case 401:
// Unauthorized - redirect to login
clearAuthTokens();
redirectToLogin();
break;
case 403:
// Forbidden - show permission denied
showMessage('You do not have permission to perform this action');
break;
case 404:
// Not found
showMessage('The requested resource was not found');
break;
case 409:
// Conflict - resource already exists
showMessage('This item already exists');
break;
case 422:
// Validation error
showValidationErrors(error.data.errors);
break;
case 429:
// Rate limited
const retryAfter = error.data.retryAfter || 60;
showMessage(`Too many requests. Try again in ${retryAfter} seconds`);
break;
case 500:
case 502:
case 503:
case 504:
// Server error - show generic error, maybe retry
showMessage('Something went wrong. Please try again later.');
logErrorToService(error);
break;
default:
showMessage('An unexpected error occurred');
logErrorToService(error);
}
}
// Usage in components
async function fetchUserProfile() {
try {
const profile = await fetchWithErrorHandling('/api/profile');
displayProfile(profile);
} catch (error) {
handleApiError(error);
}
}
Retry Logic with Exponential Backoff
// Retry failed requests with increasing delays
async function fetchWithRetry(url, options = {}, config = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 30000,
retryableStatuses = [408, 429, 500, 502, 503, 504]
} = config;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (!response.ok) {
const error = new Error('Request failed');
error.status = response.status;
error.response = response;
throw error;
}
return await response.json();
} catch (error) {
lastError = error;
// Check if error is retryable
const isRetryable =
error.name === 'TypeError' || // Network error
retryableStatuses.includes(error.status);
// Don't retry if not retryable or last attempt
if (!isRetryable || attempt === maxRetries) {
throw error;
}
// Calculate delay with exponential backoff + jitter
const delay = Math.min(
baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
maxDelay
);
console.log(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
await sleep(delay);
}
}
throw lastError;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Usage
try {
const data = await fetchWithRetry('/api/data', {}, {
maxRetries: 5,
baseDelay: 500
});
} catch (error) {
console.error('All retries failed:', error);
}
Request Timeout Handling
// Add timeout to fetch requests
async function fetchWithTimeout(url, options = {}, timeout = 10000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new TimeoutError(`Request timed out after ${timeout}ms`);
}
throw error;
}
}
// Combine timeout with retry
async function robustFetch(url, options = {}) {
const config = {
timeout: 10000,
maxRetries: 3,
...options
};
return fetchWithRetry(
url,
{
...options,
signal: AbortSignal.timeout(config.timeout)
},
{ maxRetries: config.maxRetries }
);
}
// Usage
try {
const data = await robustFetch('/api/slow-endpoint', {
timeout: 30000, // 30 second timeout
maxRetries: 5 // Retry up to 5 times
});
} catch (error) {
if (error instanceof TimeoutError) {
showMessage('The request is taking too long. Please try again.');
}
}
Fallback Strategies
// Fallback patterns for resilient applications
// 1. Cache fallback - use cached data when API fails
async function fetchWithCacheFallback(key, fetchFn) {
try {
const data = await fetchFn();
// Cache successful response
localStorage.setItem(key, JSON.stringify({
data,
timestamp: Date.now()
}));
return data;
} catch (error) {
console.warn('API failed, trying cache:', error);
// Try to use cached data
const cached = localStorage.getItem(key);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
console.log(`Using cached data from ${new Date(timestamp)}`);
return data;
}
throw error;
}
}
// 2. Default value fallback
async function fetchWithDefault(fetchFn, defaultValue) {
try {
return await fetchFn();
} catch (error) {
console.warn('Using default value due to error:', error);
return defaultValue;
}
}
// 3. Fallback API endpoint
async function fetchWithFallbackEndpoint(primaryUrl, fallbackUrl, options) {
try {
return await fetchWithErrorHandling(primaryUrl, options);
} catch (error) {
console.warn('Primary API failed, trying fallback:', error);
return await fetchWithErrorHandling(fallbackUrl, options);
}
}
// 4. Graceful degradation
async function loadDashboard() {
const [userData, postsData, statsData] = await Promise.allSettled([
fetchWithDefault(() => api.get('/user'), null),
fetchWithDefault(() => api.get('/posts'), []),
fetchWithDefault(() => api.get('/stats'), { views: 0, likes: 0 })
]);
return {
user: userData.value,
posts: postsData.value,
stats: statsData.value
};
}
// Usage
const dashboard = await loadDashboard();
// Even if some APIs fail, dashboard still loads with defaults
User-Friendly Error Messages
// Map technical errors to user-friendly messages
const errorMessages = {
network: {
title: 'Connection Problem',
message: 'Please check your internet connection and try again.',
action: 'Retry'
},
timeout: {
title: 'Request Timeout',
message: 'The server is taking too long to respond. Please try again.',
action: 'Retry'
},
unauthorized: {
title: 'Session Expired',
message: 'Please log in again to continue.',
action: 'Log In'
},
forbidden: {
title: 'Access Denied',
message: 'You do not have permission to access this resource.',
action: 'Go Back'
},
notFound: {
title: 'Not Found',
message: 'The requested item could not be found.',
action: 'Go Home'
},
validation: {
title: 'Invalid Data',
message: 'Please check your input and try again.',
action: 'Fix Errors'
},
rateLimit: {
title: 'Too Many Requests',
message: 'Please wait a moment before trying again.',
action: 'Wait'
},
server: {
title: 'Server Error',
message: 'Something went wrong on our end. Please try again later.',
action: 'Retry'
},
unknown: {
title: 'Error',
message: 'An unexpected error occurred.',
action: 'Retry'
}
};
function getErrorDisplay(error) {
if (error instanceof NetworkError) {
return errorMessages.network;
}
if (error instanceof TimeoutError) {
return errorMessages.timeout;
}
switch (error.status) {
case 401: return errorMessages.unauthorized;
case 403: return errorMessages.forbidden;
case 404: return errorMessages.notFound;
case 422: return errorMessages.validation;
case 429: return errorMessages.rateLimit;
case 500:
case 502:
case 503:
case 504: return errorMessages.server;
default: return errorMessages.unknown;
}
}
// Error display component (React example)
function ErrorDisplay({ error, onRetry, onDismiss }) {
const { title, message, action } = getErrorDisplay(error);
return (
{title}
{message}
{error.status === 422 && error.errors && (
{error.errors.map(e => - {e.message}
)}
)}
);
}
Global Error Handler
// Centralized error handling for the entire app
class ErrorHandler {
constructor() {
this.errorListeners = [];
this.errorLog = [];
}
// Register error listener
onError(callback) {
this.errorListeners.push(callback);
return () => {
this.errorListeners = this.errorListeners.filter(cb => cb !== callback);
};
}
// Handle an error
handle(error, context = {}) {
// Log error
const errorEntry = {
error,
context,
timestamp: new Date().toISOString(),
url: window.location.href
};
this.errorLog.push(errorEntry);
// Notify listeners
this.errorListeners.forEach(listener => {
try {
listener(error, context);
} catch (e) {
console.error('Error in error listener:', e);
}
});
// Send to error tracking service
this.reportToService(errorEntry);
// Return user-friendly error
return getErrorDisplay(error);
}
async reportToService(errorEntry) {
try {
await fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorEntry)
});
} catch {
// Silently fail - don't cause more errors
}
}
}
// Global instance
const errorHandler = new ErrorHandler();
// Setup global listener
errorHandler.onError((error, context) => {
// Show toast notification
showToast(getErrorDisplay(error).message, 'error');
});
// Usage in API calls
async function fetchData() {
try {
return await api.get('/data');
} catch (error) {
errorHandler.handle(error, { action: 'fetchData' });
throw error;
}
}
💡 Error Handling Best Practices
- ✓ Never silently fail - Always inform users of errors
- ✓ Use specific error types - Distinguish network, auth, validation errors
- ✓ Implement retries carefully - Only for idempotent operations
- ✓ Show actionable messages - Tell users what they can do
- ✓ Log errors for debugging - Include context and stack traces
- ✓ Provide fallbacks - Cache, defaults, or graceful degradation