Advanced API Error Handling in JS: Full Guide

A complete guide to advanced API error handling in JavaScript. Covers custom error classes, structured error responses, HTTP status code mapping, retry-safe vs non-retry-safe errors, global error boundaries, centralized error logging, graceful degradation, circuit breaker pattern, and user-friendly error messages.

JavaScriptintermediate
16 min read

Basic try/catch blocks around fetch calls are not enough for production applications. Real-world APIs fail in dozens of ways: network timeouts, rate limits, validation errors, auth expiration, server crashes, and malformed responses. This guide builds a robust error handling architecture that categorizes, retries, logs, and gracefully recovers from every failure type.

The Problem With Basic Error Handling

javascriptjavascript
// This is NOT sufficient for production
async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    return await res.json();
  } catch (err) {
    console.error(err);
    return null;
  }
}

This approach has three critical flaws: it treats all errors the same, it swallows the error context, and it silently returns null which can cause cascading bugs downstream.

Custom Error Classes

Build a hierarchy of error types so calling code can decide how to respond:

javascriptjavascript
class ApiError extends Error {
  constructor(message, statusCode, details = null) {
    super(message);
    this.name = "ApiError";
    this.statusCode = statusCode;
    this.details = details;
    this.timestamp = new Date().toISOString();
    this.isRetryable = false;
  }
}
 
class NetworkError extends ApiError {
  constructor(message = "Network request failed") {
    super(message, 0);
    this.name = "NetworkError";
    this.isRetryable = true;
  }
}
 
class ValidationError extends ApiError {
  constructor(message, fieldErrors) {
    super(message, 422, fieldErrors);
    this.name = "ValidationError";
    this.isRetryable = false;
  }
}
 
class AuthError extends ApiError {
  constructor(message = "Authentication required") {
    super(message, 401);
    this.name = "AuthError";
    this.isRetryable = false;
  }
}
 
class RateLimitError extends ApiError {
  constructor(retryAfter = 60) {
    super("Too many requests", 429);
    this.name = "RateLimitError";
    this.retryAfter = retryAfter;
    this.isRetryable = true;
  }
}
 
class ServerError extends ApiError {
  constructor(message = "Internal server error", statusCode = 500) {
    super(message, statusCode);
    this.name = "ServerError";
    this.isRetryable = true;
  }
}

HTTP Status Code Mapping

Status CodeError ClassRetryableAction
0 (no response)NetworkErrorYesRetry with backoff
400ApiErrorNoShow validation message
401AuthErrorNoRedirect to login or refresh token
403ApiErrorNoShow permission denied
404ApiErrorNoShow not found message
409ApiErrorNoShow conflict message
422ValidationErrorNoHighlight invalid fields
429RateLimitErrorYesWait Retry-After seconds
500-599ServerErrorYesRetry with exponential backoff

Response Parser

javascriptjavascript
async function parseErrorResponse(response) {
  const statusCode = response.status;
 
  let body = {};
  try {
    body = await response.json();
  } catch {
    body = { message: response.statusText };
  }
 
  const message = body.message || body.error || `Request failed with status ${statusCode}`;
 
  switch (true) {
    case statusCode === 401:
      return new AuthError(message);
 
    case statusCode === 422:
      return new ValidationError(message, body.errors || body.fields || null);
 
    case statusCode === 429: {
      const retryAfter = parseInt(response.headers.get("Retry-After") || "60", 10);
      return new RateLimitError(retryAfter);
    }
 
    case statusCode >= 500:
      return new ServerError(message, statusCode);
 
    default:
      return new ApiError(message, statusCode, body.details || null);
  }
}

Centralized Fetch Wrapper

javascriptjavascript
async function apiFetch(url, options = {}) {
  let response;
 
  try {
    response = await fetch(url, {
      ...options,
      headers: {
        "Content-Type": "application/json",
        ...options.headers,
      },
    });
  } catch (networkErr) {
    throw new NetworkError(networkErr.message);
  }
 
  if (!response.ok) {
    throw await parseErrorResponse(response);
  }
 
  // Handle 204 No Content
  if (response.status === 204) return null;
 
  try {
    return await response.json();
  } catch {
    throw new ApiError("Invalid JSON response", response.status);
  }
}

See how to use the JS Fetch API complete tutorial for Fetch fundamentals this wrapper builds on.

Retry Logic for Retryable Errors

javascriptjavascript
async function apiFetchWithRetry(url, options = {}, maxRetries = 3) {
  let lastError;
 
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await apiFetch(url, options);
    } catch (error) {
      lastError = error;
 
      if (!error.isRetryable || attempt === maxRetries) {
        throw error;
      }
 
      if (error instanceof RateLimitError) {
        await sleep(error.retryAfter * 1000);
      } else {
        const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
        await sleep(delay);
      }
 
      console.warn(`Retry ${attempt + 1}/${maxRetries} for ${url}`);
    }
  }
 
  throw lastError;
}
 
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Circuit Breaker Pattern

When a service is consistently failing, stop sending requests to let it recover:

javascriptjavascript
class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000;
    this.state = "CLOSED";        // CLOSED = normal, OPEN = blocking, HALF_OPEN = testing
    this.failureCount = 0;
    this.lastFailureTime = null;
  }
 
  async execute(fn) {
    if (this.state === "OPEN") {
      if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
        this.state = "HALF_OPEN";
      } else {
        throw new ApiError("Circuit breaker is open. Service unavailable.", 503);
      }
    }
 
    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.failureThreshold) {
      this.state = "OPEN";
    }
  }
}
 
// Usage
const userServiceBreaker = new CircuitBreaker({ failureThreshold: 3, resetTimeout: 15000 });
 
async function getUser(id) {
  return userServiceBreaker.execute(() => apiFetchWithRetry(`/api/users/${id}`));
}

Circuit Breaker States

StateBehaviorTransition
CLOSEDAll requests pass through normallyMoves to OPEN after N consecutive failures
OPENAll requests immediately rejected (no network call)Moves to HALF_OPEN after timeout expires
HALF_OPENOne test request allowed throughMoves to CLOSED on success, OPEN on failure

Centralized Error Logger

javascriptjavascript
class ErrorLogger {
  static log(error, context = {}) {
    const entry = {
      name: error.name,
      message: error.message,
      statusCode: error.statusCode,
      isRetryable: error.isRetryable,
      timestamp: error.timestamp || new Date().toISOString(),
      url: context.url || "unknown",
      method: context.method || "unknown",
      userId: context.userId || null,
      stack: error.stack,
    };
 
    // Console in development
    if (process.env.NODE_ENV !== "production") {
      console.error("[API Error]", entry);
      return;
    }
 
    // Send to monitoring service in production
    navigator.sendBeacon("/api/logs/error", JSON.stringify(entry));
  }
}
 
// Integrate with fetch wrapper
async function apiFetch(url, options = {}) {
  try {
    // ... fetch logic from above
  } catch (error) {
    ErrorLogger.log(error, { url, method: options.method || "GET" });
    throw error;
  }
}

User-Friendly Error Messages

javascriptjavascript
function getUserMessage(error) {
  if (error instanceof NetworkError) {
    return "Unable to connect. Please check your internet connection and try again.";
  }
  if (error instanceof AuthError) {
    return "Your session has expired. Please log in again.";
  }
  if (error instanceof ValidationError) {
    return "Please correct the highlighted fields and try again.";
  }
  if (error instanceof RateLimitError) {
    return `Too many requests. Please wait ${error.retryAfter} seconds.`;
  }
  if (error instanceof ServerError) {
    return "Something went wrong on our end. Please try again in a few minutes.";
  }
  return "An unexpected error occurred. Please try again.";
}

Putting It All Together

javascriptjavascript
async function handleFormSubmit(formData) {
  const statusEl = document.getElementById("status");
  const submitBtn = document.getElementById("submit");
 
  submitBtn.disabled = true;
  statusEl.textContent = "Submitting...";
  statusEl.className = "status info";
 
  try {
    const result = await apiFetchWithRetry("/api/submit", {
      method: "POST",
      body: JSON.stringify(formData),
    });
 
    statusEl.textContent = "Submitted successfully!";
    statusEl.className = "status success";
    return result;
  } catch (error) {
    statusEl.textContent = getUserMessage(error);
    statusEl.className = "status error";
 
    if (error instanceof ValidationError && error.details) {
      highlightFields(error.details);
    }
 
    if (error instanceof AuthError) {
      setTimeout(() => window.location.href = "/auth", 2000);
    }
  } finally {
    submitBtn.disabled = false;
  }
}
Rune AI

Rune AI

Key Insights

  • Custom error classes enable type-safe handling: Use instanceof checks to branch logic cleanly instead of parsing status codes in every catch block
  • Only retry transient errors: Network failures, 429, and 5xx are retryable; 4xx errors (except 429) indicate permanent client-side issues
  • Circuit breakers prevent cascading failures: Stop calling a failing service after N failures and auto-recover after a cooldown period
  • Separate user messages from developer logs: Log the full error with stack traces for debugging; show friendly messages to users
  • Centralize error parsing: One function that maps HTTP responses to error classes keeps error handling consistent across the entire application
RunePowered by Rune AI

Frequently Asked Questions

Should I use Axios error handling or build my own with Fetch?

If you already use Axios, its built-in error categorization (`error.response`, `error.request`) is a good starting point. For Fetch-based projects, building custom error classes gives you more control and avoids the Axios dependency. See [how to use Axios in JavaScript complete guide](/tutorials/programming-languages/javascript/how-to-use-axios-in-javascript-complete-guide) for the Axios approach.

When should I use the circuit breaker pattern?

Use it when your app depends on an external service that may go down. The circuit breaker prevents flooding a struggling service with requests and gives your app a fast failure path instead of waiting for timeouts.

How do I handle errors in Promise.all?

`Promise.all` rejects on the first error. Use `Promise.allSettled` to get results from all promises, then filter by `status === "rejected"` to handle individual failures.

Should I retry 401 errors?

Not directly. A 401 means the credentials are invalid. Instead, use a token refresh flow: detect 401, refresh the access token, then replay the original request exactly once. See [axios interceptors in JavaScript complete guide](/tutorials/programming-languages/javascript/axios-interceptors-in-javascript-complete-guide) for this pattern.

Conclusion

Advanced API error handling requires custom error classes for type-safe branching, a centralized fetch wrapper that maps HTTP status codes to error types, retry logic that only retries transient failures, circuit breakers that prevent cascading failures, centralized logging for production monitoring, and user-friendly messages for the UI. For the Fetch API foundation, see handling POST requests with JS Fetch API guide. For the event loop mechanics behind async error propagation, see the JS event loop architecture complete guide.