Integration · 14 min read

Error Handling & Retry Strategies

A comprehensive guide to handling rate limits, timeouts, server errors, and network failures in production sports API applications. Covers exponential backoff, circuit breakers, and graceful degradation.

Last updated: August 2026

Overview

Sports APIs fail in many ways. Rate limits return HTTP 429. Server errors return 500 or 503. Network timeouts leave requests hanging. Malformed responses break JSON parsing. Without a robust error handling strategy, a single API hiccup can cascade into a full application outage.

The goal is resilience: your application should degrade gracefully when things go wrong, retry transient failures automatically, and fail fast when errors are permanent. This guide covers the three pillars of API resilience: retry with backoff, circuit breakers, and graceful degradation.

Classifying API Errors

Error TypeHTTP CodeRetry?
Rate limited429Yes (after delay)
Server error500, 502, 503Yes (with backoff)
Timeout-Yes (with backoff)
Network error-Yes (with backoff)
Bad request400No (fix request)
Unauthorized401, 403No (fix auth)
Not found404No (check ID)

Architecture

A resilient API client wraps every request in three layers of protection:

Resilience Layers

  Request ──→ Circuit Breaker ──→ Retry w/ Backoff ──→ API Call
                 │                      │                   │
                 │                      │←── Retry on 429/5xx
                 │                      │←── Retry on timeout
                 │                      │
                 │←── Open if N failures
                 │
                 └── If open: fail fast
                     (skip API call entirely)

  Fallback: Serve cached/stale data if all retries exhausted

Implementation

Exponential Backoff with Jitter (JavaScript)

Exponential backoff increases the delay between retries, giving the server time to recover. Adding jitter (randomness) prevents thundering herd problems where all clients retry simultaneously:

// resilientFetch.js

const RETRYABLE_STATUS = [429, 500, 502, 503, 504];
const MAX_RETRIES = 4;
const BASE_DELAY_MS = 1000;
const MAX_DELAY_MS = 30_000;

/**
 * Calculate delay for exponential backoff with jitter.
 * delay = min(base * 2^attempt + random_jitter, max)
 */
function getRetryDelay(attempt, retryAfterHeader) {
  // Respect Retry-After header if provided (seconds)
  if (retryAfterHeader) {
    return Math.min(
      parseInt(retryAfterHeader) * 1000,
      MAX_DELAY_MS
    );
  }

  const exponential = BASE_DELAY_MS * Math.pow(2, attempt);
  const jitter = Math.random() * 1000; // 0-1000ms random jitter
  return Math.min(exponential + jitter, MAX_DELAY_MS);
}

function isRetryable(error, response) {
  // Network errors are always retryable
  if (error) return true;

  // Check status code
  if (RETRYABLE_STATUS.includes(response.status)) return true;

  return false;
}

/**
 * Fetch with automatic retry and exponential backoff.
 */
async function resilientFetch(url, options = {}, attempt = 0) {
  try {
    const response = await fetch(url, {
      ...options,
      signal: AbortSignal.timeout(10_000), // 10s timeout
    });

    if (!isRetryable(null, response)) {
      if (!response.ok) {
        throw new ApiError(
          `API returned ${response.status}`,
          response.status,
          false // not retryable
        );
      }
      return response;
    }

    // Retryable error
    if (attempt >= MAX_RETRIES) {
      throw new ApiError(
        `Max retries (${MAX_RETRIES}) exceeded`,
        response.status,
        false
      );
    }

    const retryAfter = response.headers.get("Retry-After");
    const delay = getRetryDelay(attempt, retryAfter);

    console.warn(
      `Retry ${attempt + 1}/${MAX_RETRIES} after ${Math.round(delay)}ms ` +
      `(${response.status})`
    );

    await new Promise((resolve) => setTimeout(resolve, delay));
    return resilientFetch(url, options, attempt + 1);

  } catch (error) {
    if (error instanceof ApiError) throw error;

    // Network error or timeout
    if (attempt >= MAX_RETRIES) {
      throw new ApiError(
        `Network error after ${MAX_RETRIES} retries: ${error.message}`,
        0,
        false
      );
    }

    const delay = getRetryDelay(attempt);
    console.warn(
      `Retry ${attempt + 1}/${MAX_RETRIES} after ${Math.round(delay)}ms ` +
      `(${error.name})`
    );

    await new Promise((resolve) => setTimeout(resolve, delay));
    return resilientFetch(url, options, attempt + 1);
  }
}

class ApiError extends Error {
  constructor(message, status, retryable) {
    super(message);
    this.name = "ApiError";
    this.status = status;
    this.retryable = retryable;
  }
}

export { resilientFetch, ApiError };

Circuit Breaker (Python)

A circuit breaker stops sending requests to a failing service after a threshold of failures. This prevents cascading failures and gives the service time to recover:

# circuit_breaker.py
import time
import asyncio
from enum import Enum

class CircuitState(Enum):
    CLOSED = "CLOSED"     # Normal operation
    OPEN = "OPEN"         # Failing — requests blocked
    HALF_OPEN = "HALF_OPEN"  # Testing if service recovered

class CircuitBreaker:
    def __init__(
        self,
        failure_threshold=5,
        recovery_timeout=60,
        half_open_max_calls=1,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.half_open_max_calls = half_open_max_calls
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.success_count = 0
        self.last_failure_time = None
        self.half_open_calls = 0

    async def call(self, func, *args, **kwargs):
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                print("[CircuitBreaker] Transitioning to HALF_OPEN")
                self.state = CircuitState.HALF_OPEN
                self.half_open_calls = 0
            else:
                raise CircuitOpenError(
                    "Circuit is open — failing fast. "
                    f"Retry in {int(self.recovery_timeout - "
                    f"(time.time() - self.last_failure_time))}s"
                )

        if self.state == CircuitState.HALF_OPEN:
            if self.half_open_calls >= self.half_open_max_calls:
                raise CircuitOpenError(
                    "Circuit is half-open — waiting for test call"
                )
            self.half_open_calls += 1

        try:
            result = await func(*args, **kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise

    def _on_success(self):
        self.failure_count = 0
        if self.state == CircuitState.HALF_OPEN:
            self.success_count += 1
            if self.success_count >= 2:
                print("[CircuitBreaker] Transitioning to CLOSED")
                self.state = CircuitState.CLOSED
                self.success_count = 0

    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        self.success_count = 0

        if self.state == CircuitState.HALF_OPEN:
            print("[CircuitBreaker] HALF_OPEN failed — back to OPEN")
            self.state = CircuitState.OPEN
        elif self.failure_count >= self.failure_threshold:
            print(f"[CircuitBreaker] Threshold reached ({self.failure_count}) "
                  f"— OPEN")
            self.state = CircuitState.OPEN

class CircuitOpenError(Exception):
    pass

# Usage with a sports API client
import httpx

breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60)

async def fetch_fixtures(league_id):
    async def api_call():
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                "https://v3.football.api-sports.io/fixtures",
                params={"league": league_id, "season": 2025},
                headers={"x-apisports-key": "your-key"},
                timeout=10,
            )
            resp.raise_for_status()
            return resp.json()

    try:
        return await breaker.call(api_call)
    except CircuitOpenError:
        # Fallback: serve cached data
        print("Serving cached data (circuit open)")
        return get_cached_fixtures(league_id)

Code Examples

Complete Resilient Client

Putting it all together: a client that combines retry, circuit breaker, and cache fallback for maximum resilience:

// SportsApiClient.js
import { resilientFetch, ApiError } from "./resilientFetch.js";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

class SportsApiClient {
  constructor(apiKey, baseUrl) {
    this.apiKey = apiKey;
    this.baseUrl = baseUrl;
  }

  async get(endpoint, params = {}) {
    const url = new URL(`${this.baseUrl}/${endpoint}`);
    Object.entries(params).forEach(([k, v]) =>
      url.searchParams.set(k, v)
    );

    const cacheKey = `api:${url.pathname}:${url.search}`;

    try {
      const response = await resilientFetch(url.toString(), {
        headers: { "x-apisports-key": this.apiKey },
      });

      const data = await response.json();

      // Cache successful responses
      await redis.setex(cacheKey, 60, JSON.stringify(data));

      return { data, source: "api", stale: false };

    } catch (error) {
      if (error instanceof ApiError) {
        console.error(`API error: ${error.message} (${error.status})`);
      } else {
        console.error(`Unexpected error: ${error.message}`);
      }

      // Fallback 1: Try cache (even if stale)
      const cached = await redis.get(cacheKey);
      if (cached) {
        console.log("Serving cached data as fallback");
        return {
          data: JSON.parse(cached),
          source: "cache",
          stale: true,
        };
      }

      // Fallback 2: Try a simpler endpoint or default data
      const defaultData = this.getDefaultData(endpoint, params);
      if (defaultData) {
        console.log("Serving default data as fallback");
        return {
          data: defaultData,
          source: "default",
          stale: true,
        };
      }

      // All fallbacks exhausted
      throw new Error(
        `Unable to fetch ${endpoint}: ${error.message}. No cache or fallback available.`
      );
    }
  }

  getDefaultData(endpoint, params) {
    // Return minimal default data for critical endpoints
    if (endpoint === "fixtures" && params.live) {
      return { response: [], message: "No live data available" };
    }
    return null;
  }
}

// Usage
const client = new SportsApiClient(
  process.env.SPORTS_API_KEY,
  "https://v3.football.api-sports.io"
);

const result = await client.get("fixtures", { live: "all" });

if (result.stale) {
  console.warn("Showing potentially outdated data");
}

Best Practices

Never retry non-idempotent errors

HTTP 400 (bad request), 401 (unauthorized), and 404 (not found) will fail every time. Retrying them wastes resources and can mask real bugs. Only retry transient failures: 429, 500, 502, 503, 504, and network timeouts.

Always add jitter to retry delays

Without jitter, all clients that received a 503 will retry at exactly the same interval, creating a synchronized load spike. Random jitter spreads retries across time, reducing load on the recovering server.

Set aggressive timeouts

A hanging request is worse than a failed one. Set a 10-15 second timeout for sports API calls. If the API does not respond in time, treat it as a retryable failure and move on. See our rate limit handling guide for request budgeting.

Degrade gracefully, never show a blank screen

When all retries and fallbacks fail, show the last known data with a stale indicator, or a friendly error message with a retry button. Never leave users staring at a blank page or a raw stack trace.

Related Guides

Find reliable APIs with good error documentation

Compare providers by uptime, status pages, and error handling.