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