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 Type | HTTP Code | Retry? |
|---|---|---|
| Rate limited | 429 | Yes (after delay) |
| Server error | 500, 502, 503 | Yes (with backoff) |
| Timeout | - | Yes (with backoff) |
| Network error | - | Yes (with backoff) |
| Bad request | 400 | No (fix request) |
| Unauthorized | 401, 403 | No (fix auth) |
| Not found | 404 | No (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 exhaustedImplementation
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
Handling API Rate Limits
13 min readBuilding Failover for Sports APIs
12 min readCaching Strategies for Sports API Data
12 min readFind reliable APIs with good error documentation
Compare providers by uptime, status pages, and error handling.