DEV Community

Cover image for API Error Handling: Patterns That Actually Work
APIVerve
APIVerve

Posted on • Originally published at blog.apiverve.com

API Error Handling: Patterns That Actually Work

A third-party weather API goes down. Not a big deal—except the app has zero error handling. It tries to fetch weather data, gets no response, and crashes. Every user hits a blank screen with "undefined is not an object."

The weather data wasn't even important. Nice-to-have feature in the corner of the dashboard. But because no one handled the failure case, a 15-minute outage at some weather service turned into thousands of error reports and an emergency hotfix.

The fix takes 20 minutes. The proper error handling should have been there from day one.

The Truth About API Calls

Here's what nobody tells junior developers: every API call will fail eventually.

Not "might fail" or "could potentially fail under rare circumstances." Will fail. The question is when and how often.

Networks drop. Services have outages. You'll hit rate limits at the worst possible moment. DNS has hiccups. TLS certificates expire. Data centers catch fire (really, this has happened). The cloud region you picked gets overwhelmed during a major event.

The apps that survive aren't the ones with the most reliable API providers. They're the ones that handle failure gracefully.

The Four Ways Everything Breaks

API integration issues fall into exactly four categories:

1. Network Failures — The request never left your app (or never arrived)

  • No internet connection
  • DNS resolution failed
  • CORS blocked the request
  • Firewall issues
  • Request was interrupted mid-flight

2. HTTP Errors — The server received your request and explicitly said no

  • 401: Your credentials are wrong
  • 403: Your credentials are right but you don't have permission
  • 404: That endpoint doesn't exist
  • 429: You're sending too many requests
  • 500: The server is broken
  • 502/503/504: Something between you and the server is broken

3. API-Level Errors — HTTP says 200 OK, but the response contains an error

  • { "status": "error", "error": "Invalid email format" }
  • The request was technically successful, the business logic failed

4. Timeouts — The request went out, but nobody answered in time

  • Server is slow
  • Server is overloaded
  • Network congestion
  • Request got lost somewhere

Handle all four or your error handling has holes.

Pattern 1: The Basic Wrapper

This is the foundation. Everything else builds on this.

class APIError extends Error {
  constructor(message, statusCode, originalError = null) {
    super(message);
    this.name = 'APIError';
    this.statusCode = statusCode;
    this.originalError = originalError;
    this.timestamp = new Date().toISOString();
  }

  get isRetryable() {
    return this.statusCode === 0 ||      // Network error
           this.statusCode === 429 ||    // Rate limited
           this.statusCode >= 500;       // Server error
  }

  get isClientError() {
    return this.statusCode >= 400 && this.statusCode < 500;
  }
}

async function callAPI(url, options = {}) {
  try {
    const response = await fetch(url, {
      ...options,
      headers: {
        'x-api-key': process.env.APIVERVE_KEY,
        ...options.headers
      }
    });

    // HTTP errors
    if (!response.ok) {
      const body = await response.text();
      throw new APIError(
        `HTTP ${response.status}: ${body || response.statusText}`,
        response.status
      );
    }

    const json = await response.json();

    // API-level errors
    if (json.status === 'error' || json.error) {
      throw new APIError(json.error || 'Unknown API error', response.status);
    }

    return json.data;

  } catch (err) {
    // Already our error type
    if (err instanceof APIError) throw err;

    // Network failures
    if (err.name === 'TypeError' && err.message.includes('fetch')) {
      throw new APIError('Network error: Unable to reach server', 0, err);
    }

    // JSON parsing failures
    if (err instanceof SyntaxError) {
      throw new APIError('Invalid response: Could not parse JSON', 0, err);
    }

    // Anything else
    throw new APIError(`Unexpected error: ${err.message}`, 0, err);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now every failure becomes an APIError with a status code. Network failures get statusCode: 0. The isRetryable getter tells you whether it's worth trying again.

Why the originalError property? Because when you're debugging at 3am, you want the full stack trace, not just your wrapper's sanitized message.

Pattern 2: Retry with Exponential Backoff

Some failures fix themselves. Server hiccups. Brief network blips. Rate limits that expire. Retry those.

But retry intelligently. If you hammer a struggling server with immediate retries, you're making the problem worse.

async function callAPIWithRetry(url, options = {}, config = {}) {
  const {
    maxRetries = 3,
    baseDelay = 1000,
    maxDelay = 30000,
    onRetry = () => {}
  } = config;

  let lastError;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await callAPI(url, options);

    } catch (err) {
      lastError = err;

      // Don't retry client errors (except rate limits)
      if (err instanceof APIError && err.isClientError && err.statusCode !== 429) {
        throw err;
      }

      // No more retries
      if (attempt === maxRetries) break;

      // Calculate delay with exponential backoff + jitter
      const exponentialDelay = baseDelay * Math.pow(2, attempt);
      const jitter = Math.random() * 0.3 * exponentialDelay; // 0-30% jitter
      const delay = Math.min(exponentialDelay + jitter, maxDelay);

      onRetry({
        attempt: attempt + 1,
        maxRetries,
        error: err,
        nextRetryIn: delay
      });

      await sleep(delay);
    }
  }

  throw lastError;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}
Enter fullscreen mode Exit fullscreen mode

The jitter is important. If a server goes down and comes back up, you don't want 10,000 clients all retrying at exactly 1 second, then exactly 2 seconds. The random variation spreads out the load.

What to retry:

  • Status 0 (network errors)
  • Status 429 (rate limited)
  • Status 500, 502, 503, 504 (server issues)
  • Timeouts

What NOT to retry:

  • Status 400 (bad request) — your input is wrong
  • Status 401 (unauthorized) — your credentials are wrong
  • Status 403 (forbidden) — you don't have permission
  • Status 404 (not found) — the resource doesn't exist

Retrying a 401 forever is just wasted API calls. The auth token isn't going to magically become valid.

Pattern 3: Timeouts

Here's a fun fact that will ruin your day: fetch doesn't timeout by default.

If the server accepts your connection and then just... stops responding, your request will hang forever. Or until the operating system's TCP timeout kicks in, which could be minutes.

async function callAPIWithTimeout(url, options = {}, timeoutMs = 10000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
      headers: {
        'x-api-key': process.env.APIVERVE_KEY,
        ...options.headers
      }
    });

    clearTimeout(timeoutId);

    if (!response.ok) {
      throw new APIError(`HTTP ${response.status}`, response.status);
    }

    const json = await response.json();
    if (json.status === 'error') throw new APIError(json.error, response.status);

    return json.data;

  } catch (err) {
    clearTimeout(timeoutId);

    if (err.name === 'AbortError') {
      throw new APIError(
        `Request timed out after ${timeoutMs}ms`,
        408 // Request Timeout
      );
    }

    if (err instanceof APIError) throw err;
    throw new APIError(`Network error: ${err.message}`, 0, err);
  }
}
Enter fullscreen mode Exit fullscreen mode

How long should your timeout be? Depends on the API:

API Type Reasonable Timeout
Simple lookups (email, IP) 5-10 seconds
Data processing 15-30 seconds
PDF/image generation 30-60 seconds
Heavy computation 60-120 seconds

If an API consistently takes longer than your timeout, either your timeout is too short or that API has performance problems.

Pattern 4: Circuit Breaker

Imagine an API goes down for 5 minutes. With retry logic, every request tries 3 times, waits, tries again. You're hammering a dead server and making your users wait for failures.

The circuit breaker pattern fixes this: after enough consecutive failures, stop trying. Give the server a break. Check back later.

class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000;
    this.monitorWindow = options.monitorWindow || 60000;

    this.state = 'closed';
    this.failures = [];
    this.lastFailure = null;
    this.nextAttempt = 0;
  }

  async call(fn) {
    // If circuit is open, check if we should try again
    if (this.state === 'open') {
      if (Date.now() < this.nextAttempt) {
        throw new APIError(
          `Circuit breaker open. Service unavailable. Retry after ${new Date(this.nextAttempt).toISOString()}`,
          503
        );
      }
      // Try a single request to test if service is back
      this.state = 'half-open';
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;

    } catch (err) {
      this.onFailure();
      throw err;
    }
  }

  onSuccess() {
    // Service is working, reset everything
    this.failures = [];
    this.state = 'closed';
  }

  onFailure() {
    const now = Date.now();
    this.lastFailure = now;

    // Remove old failures outside monitoring window
    this.failures = this.failures.filter(t => now - t < this.monitorWindow);
    this.failures.push(now);

    // If half-open test failed, go back to open
    if (this.state === 'half-open') {
      this.state = 'open';
      this.nextAttempt = now + this.resetTimeout;
      return;
    }

    // Check if we've hit threshold
    if (this.failures.length >= this.failureThreshold) {
      this.state = 'open';
      this.nextAttempt = now + this.resetTimeout;
      console.warn(`Circuit breaker opened after ${this.failures.length} failures`);
    }
  }

  getStatus() {
    return {
      state: this.state,
      recentFailures: this.failures.length,
      nextAttempt: this.state === 'open' ? new Date(this.nextAttempt) : null
    };
  }
}

// One circuit breaker per API endpoint
const breakers = new Map();

function getBreaker(endpoint) {
  if (!breakers.has(endpoint)) {
    breakers.set(endpoint, new CircuitBreaker());
  }
  return breakers.get(endpoint);
}

// Usage
async function validateEmail(email) {
  const breaker = getBreaker('emailvalidator');

  return breaker.call(() =>
    callAPIWithRetry(
      `https://api.apiverve.com/v1/emailvalidator?email=${encodeURIComponent(email)}`
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

The circuit has three states:

  • Closed: Everything's working, let requests through
  • Open: Too many failures, reject requests immediately
  • Half-open: Testing if service recovered with a single request

This protects both your users (no waiting for doomed requests) and the struggling API (no flood of retries).

Pattern 5: Graceful Degradation

Sometimes the right response to an API failure isn't "show an error." It's "work around it."

async function getExchangeRate(from, to) {
  try {
    const data = await callAPIWithRetry(
      `https://api.apiverve.com/v1/exchangerate?currency1=${from}&currency2=${to}`
    );

    // Cache the successful result
    cache.set(`rate:${from}:${to}`, {
      rate: data.rate,
      timestamp: Date.now(),
      source: 'live'
    });

    return data.rate;

  } catch (err) {
    console.warn(`Exchange rate API failed: ${err.message}`);

    // Try cache
    const cached = cache.get(`rate:${from}:${to}`);
    if (cached && Date.now() - cached.timestamp < 24 * 60 * 60 * 1000) {
      console.log('Using cached exchange rate');
      return cached.rate;
    }

    // If critical, throw
    throw new APIError(
      'Exchange rate unavailable and no cached data',
      503
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

For the weather dashboard that crashed at 2am? Here's what the code should have looked like:

async function loadWeatherWidget(location) {
  try {
    const weather = await callAPIWithTimeout(
      `https://api.apiverve.com/v1/weatherforecast?city=${encodeURIComponent(location)}`,
      {},
      5000 // Short timeout for non-critical feature
    );

    return {
      available: true,
      temperature: weather.current.temp,
      conditions: weather.current.description,
      icon: weather.current.icon
    };

  } catch (err) {
    console.warn(`Weather widget unavailable: ${err.message}`);

    // The widget is optional - don't crash, just hide it
    return {
      available: false,
      message: 'Weather temporarily unavailable'
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Now when the weather API goes down, users see "Weather temporarily unavailable" in the corner instead of a crashed app. The important features keep working.

Pattern 6: User-Friendly Error Messages

Technical errors are for logs. Human errors are for users.

function toUserMessage(err) {
  // Development mode: show the real error
  if (process.env.NODE_ENV === 'development') {
    return `[DEV] ${err.message}`;
  }

  if (!(err instanceof APIError)) {
    return "Something went wrong. Please try again.";
  }

  const messages = {
    0: "Can't connect to the server. Check your internet connection.",
    400: "There's a problem with your input. Please check and try again.",
    401: "Your session has expired. Please log in again.",
    403: "You don't have permission to do that.",
    404: "We couldn't find what you're looking for.",
    408: "The request took too long. Please try again.",
    429: "You're doing that too much. Please wait a moment.",
    500: "Our servers are having trouble. Please try again later.",
    502: "We're having connection issues. Please try again.",
    503: "This service is temporarily unavailable. Please try again later.",
    504: "The request timed out. Please try again."
  };

  return messages[err.statusCode] || "Something went wrong. Please try again.";
}

// Usage
try {
  await submitForm(data);
} catch (err) {
  // Log full details for debugging
  console.error('Form submission failed:', {
    error: err.message,
    statusCode: err.statusCode,
    originalError: err.originalError,
    timestamp: err.timestamp
  });

  // Show friendly message to user
  showToast(toUserMessage(err), 'error');
}
Enter fullscreen mode Exit fullscreen mode

The technical details go to logs where developers can see them. The user sees a message they can actually understand and act on.

The Complete Client

Here's everything combined into a production-ready API client:

class APIClient {
  constructor(baseUrl, apiKey, options = {}) {
    this.baseUrl = baseUrl;
    this.apiKey = apiKey;
    this.timeout = options.timeout || 10000;
    this.maxRetries = options.maxRetries || 3;
    this.breakers = new Map();
  }

  async request(endpoint, options = {}) {
    const breaker = this.getBreaker(endpoint);
    const url = `${this.baseUrl}${endpoint}`;

    return breaker.call(async () => {
      return this.requestWithRetry(url, options);
    });
  }

  async requestWithRetry(url, options) {
    let lastError;

    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      try {
        return await this.execute(url, options);
      } catch (err) {
        lastError = err;

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

        const delay = Math.pow(2, attempt) * 1000 * (1 + Math.random() * 0.3);
        await this.sleep(delay);
      }
    }

    throw lastError;
  }

  async execute(url, options) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.timeout);

    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal,
        headers: {
          'Content-Type': 'application/json',
          'x-api-key': this.apiKey,
          ...options.headers
        }
      });

      clearTimeout(timeoutId);

      if (!response.ok) {
        const body = await response.text();
        throw new APIError(body || `HTTP ${response.status}`, response.status);
      }

      const json = await response.json();
      if (json.status === 'error') throw new APIError(json.error, response.status);

      return json.data;

    } catch (err) {
      clearTimeout(timeoutId);

      if (err.name === 'AbortError') {
        throw new APIError('Request timeout', 408);
      }
      if (err instanceof APIError) throw err;
      throw new APIError(`Network error: ${err.message}`, 0, err);
    }
  }

  getBreaker(endpoint) {
    const key = endpoint.split('?')[0]; // Ignore query params
    if (!this.breakers.has(key)) {
      this.breakers.set(key, new CircuitBreaker());
    }
    return this.breakers.get(key);
  }

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

// Usage
const api = new APIClient('https://api.apiverve.com/v1', process.env.APIVERVE_KEY);

const email = await api.request(`/emailvalidator?email=${encodeURIComponent(input)}`);
Enter fullscreen mode Exit fullscreen mode

Timeouts. Retries with backoff. Circuit breakers. All behind a simple interface. Your calling code doesn't need to know the complexity—it just awaits and catches.

Common Mistakes

Swallowing errors silently. Don't catch (e) {}. At minimum, log it. Errors you don't know about are errors you can't fix.

Retrying everything. Don't retry 401s and 400s. They won't fix themselves. You're just wasting API calls and time.

No timeout. Default fetch waits forever. Always set a timeout.

Same error for everything. "Something went wrong" tells users nothing. Map status codes to helpful messages.

Treating all failures as equal. A weather widget failing is different from payment processing failing. Critical paths need more robust handling than nice-to-haves.

No logging. When things break in production, you need data. Log errors with context: what was the request, what was the response, when did it happen.


Error handling isn't glamorous. Nobody's writing blog posts titled "I spent two days perfecting my retry logic." But it's the difference between an app that feels solid and one that randomly breaks when the internet hiccups.

Invest the time upfront. Your future self—the one getting paged at 3am—will thank you.

The error handling documentation covers APIVerve-specific error codes, or check the rate limits guide if you're hitting 429s.

Grab an API key and build something that handles failure gracefully.


Originally published at APIVerve Blog

Top comments (0)