Error Handling Best Practices

Effective error handling is crucial for building robust API integrations. This guide covers best practices for handling API errors gracefully in your applications.

In this guide:

  • Understanding API Errors
  • Common HTTP Status Codes
  • Client-Side Error Handling
  • Server-Side Error Handling
  • Error Logging and Monitoring
  • User-Friendly Error Messages
  • Retry Strategies

Understanding API Errors

API errors can occur for various reasons, including:

  • Invalid input or parameters
  • Authentication or authorization failures
  • Resource not found
  • Rate limiting or quota exceeded
  • Server-side issues
  • Network problems

Understanding the different types of errors and how to handle them appropriately is essential for building resilient applications.

Common HTTP Status Codes

HTTP status codes are standardized codes returned by a server in response to a client's request. They are grouped into five classes:

Code RangeCategoryDescription
1xxInformationalRequest received, continuing process
2xxSuccessRequest successfully received, understood, and accepted
3xxRedirectionFurther action needs to be taken to complete the request
4xxClient ErrorRequest contains bad syntax or cannot be fulfilled
5xxServer ErrorServer failed to fulfill a valid request

Common error status codes you'll encounter when working with APIs:

Status CodeNameDescription
400Bad RequestThe request was malformed or contains invalid parameters
401UnauthorizedAuthentication is required and has failed or not been provided
403ForbiddenThe server understood the request but refuses to authorize it
404Not FoundThe requested resource could not be found
429Too Many RequestsThe user has sent too many requests in a given amount of time (rate limiting)
500Internal Server ErrorThe server encountered an unexpected condition
503Service UnavailableThe server is not ready to handle the request (temporary overload or maintenance)

Client-Side Error Handling

Proper client-side error handling is essential for creating a good user experience. Here are some best practices:

Basic Error Handling

At a minimum, always check if the response was successful before processing it:

// Basic error handling with fetch
fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return response.json();
  })
  .then(data => {
    console.log('Success:', data);
  })
  .catch(error => {
    console.error('Error:', error);
  });

Detailed Error Handling

For more robust error handling, consider different types of errors and handle them appropriately:

// More detailed error handling
async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    
    // Check for HTTP errors
    if (!response.ok) {
      // Try to parse error response
      let errorMessage;
      try {
        const errorData = await response.json();
        errorMessage = errorData.message || errorData.error || `HTTP error! Status: ${response.status}`;
      } catch (e) {
        errorMessage = `HTTP error! Status: ${response.status}`;
      }
      
      throw new Error(errorMessage);
    }
    
    const data = await response.json();
    return data;
  } catch (error) {
    // Handle network errors
    if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
      console.error('Network error: Please check your connection');
      // Implement retry logic or fallback
    } else {
      console.error('Error:', error.message);
    }
    
    // Rethrow or handle appropriately
    throw error;
  }
}

Implementing Retry Logic

For transient errors (like network issues or rate limiting), implementing retry logic with exponential backoff can improve reliability:

// Implementing retry logic
async function fetchWithRetry(url, options = {}, retries = 3, backoff = 300) {
  try {
    const response = await fetch(url, options);
    
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    // Check if we should retry
    if (retries > 0) {
      // Check if error is retryable (e.g., 429, 503, network errors)
      const isRetryable = 
        error.name === 'TypeError' || // Network error
        (error.message && error.message.includes('429')) || // Too Many Requests
        (error.message && error.message.includes('503')); // Service Unavailable
      
      if (isRetryable) {
        console.log(`Retrying... Attempts remaining: ${retries}`);
        
        // Wait for backoff duration
        await new Promise(resolve => setTimeout(resolve, backoff));
        
        // Exponential backoff
        return fetchWithRetry(url, options, retries - 1, backoff * 2);
      }
    }
    
    // If we're out of retries or error is not retryable
    throw error;
  }
}

Custom Error Classes

Creating custom error classes can help standardize error handling across your application:

// Creating custom error classes
class APIError extends Error {
  constructor(message, status, code, data = null) {
    super(message);
    this.name = 'APIError';
    this.status = status;
    this.code = code;
    this.data = data;
  }
  
  isClientError() {
    return this.status >= 400 && this.status < 500;
  }
  
  isServerError() {
    return this.status >= 500;
  }
  
  isAuthError() {
    return this.status === 401 || this.status === 403;
  }
  
  isRateLimitError() {
    return this.status === 429;
  }
}

// Using the custom error class
async function fetchData() {
  try {
    const response = await fetch('https://api.example.com/data');
    
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      throw new APIError(
        errorData.message || 'An error occurred',
        response.status,
        errorData.code || 'unknown_error',
        errorData
      );
    }
    
    return await response.json();
  } catch (error) {
    if (error instanceof APIError) {
      if (error.isAuthError()) {
        console.error('Authentication error:', error.message);
        // Redirect to login or refresh token
      } else if (error.isRateLimitError()) {
        console.error('Rate limit exceeded:', error.message);
        // Implement backoff strategy
      } else if (error.isServerError()) {
        console.error('Server error:', error.message);
        // Show appropriate UI message
      } else {
        console.error('API error:', error.message);
        // Handle other API errors
      }
    } else {
      console.error('Network or parsing error:', error);
      // Handle network errors
    }
    
    throw error;
  }
}

Server-Side Error Handling

When building APIs, it's important to provide clear and consistent error responses:

  • Use appropriate HTTP status codes
  • Include error messages that are helpful but don't expose sensitive information
  • Consider including error codes that clients can use for programmatic handling
  • Include request IDs to help with debugging and support

Example Error Response Format

{
  "error": {
    "status": 400,
    "code": "invalid_parameter",
    "message": "The 'email' parameter is not a valid email address",
    "details": {
      "parameter": "email",
      "value": "not-an-email",
      "constraint": "email"
    },
    "requestId": "req_1234567890"
  }
}

Error Logging and Monitoring

Proper error logging and monitoring are essential for identifying and resolving issues:

  • Log all errors with relevant context (request details, user information, etc.)
  • Use structured logging for easier parsing and analysis
  • Set up alerts for critical errors
  • Use error monitoring services (like Sentry, Rollbar, or New Relic) to track and analyze errors
  • Implement distributed tracing for complex systems

User-Friendly Error Messages

When displaying errors to users, consider the following:

  • Use clear, non-technical language
  • Provide actionable steps for resolution when possible
  • Don't expose sensitive information or implementation details
  • Consider the user's emotional state and use empathetic language
  • For critical errors, provide alternative contact methods or support options

Good Example:

"We couldn't save your changes because the connection was lost. Please check your internet connection and try again. If the problem persists, please contact support."

Bad Example:

"Error 500: Internal server error occurred in module XYZ. Failed to execute database query: Connection timeout after 30s."

Retry Strategies

When implementing retry logic, consider these strategies:

  • Exponential Backoff: Increase the delay between retries exponentially
  • Jitter: Add randomness to retry intervals to prevent thundering herd problems
  • Circuit Breaker Pattern: Stop retrying after a certain threshold to prevent cascading failures
  • Retry Budgets: Limit the number of retries across your system
  • Idempotency: Ensure operations can be safely retried without side effects

Conclusion

Effective error handling is a critical aspect of building robust API integrations. By implementing the best practices outlined in this guide, you can create applications that gracefully handle errors, provide a better user experience, and are easier to maintain and debug.

Key Takeaways

  • Always check for and handle errors in API responses
  • Implement retry logic for transient errors
  • Use appropriate HTTP status codes and error formats
  • Log errors with context for debugging
  • Provide user-friendly error messages
  • Consider different retry strategies for different types of errors