The Hidden Cost of Not Handling API Errors Properly (And How to Fix It in 5 Steps)
I once spent a Friday evening debugging a production outage that had nothing to do with bad code or infrastructure failure. The culprit? A third-party weather API that decided to return HTTP 500 for three hours straight. Our service didn't handle it. It just... crashed.
That night cost us five hours of sleep and a weekend's worth of customer complaints. The fix took 20 minutes. The error handling? We never thought we needed it — until we desperately did.
Here's what I learned, and a 5-step approach you can implement today.
Why Most Error Handling Falls Short
The default approach in most codebases looks something like this:
import requests
def get_weather(city):
resp = requests.get(f"https://api.weather.com/v1/{city}")
return resp.json()
This works perfectly — until it doesn't. The API goes down, the network flakes, the rate limit kicks in, and suddenly your entire request chain unravels with a cryptic traceback.
What's worse is that the way an API fails tells you what to do next. A 429 means "slow down." A 503 means "try again later." A 401 means "your token expired." But if you treat them all the same way, you're flying blind.
Step 1: Map the Error Landscape
Before writing a single line, categorise every possible failure mode:
| Category | Examples | Action |
|---|---|---|
| Client error (4xx) | 400, 401, 403, 404, 429 | Fix the request or back off |
| Server error (5xx) | 500, 502, 503, 504 | Retry with exponential backoff |
| Network error | Timeout, DNS failure, connection refused | Retry or fail gracefully |
| Data error | Malformed JSON, missing fields, type mismatch | Log and degrade |
This isn't theoretical. Every API your service depends on will hit every one of these eventually. The question is whether your code is ready when it happens.
Step 2: Build a Retry Layer That Actually Works
Naive retry looks like this:
for _ in range(3):
try:
return requests.get(url)
except:
time.sleep(1)
The problem? If the upstream is genuinely down, you're hammering it with retries at exactly the worst moment. A thundering herd of retries can turn a brief hiccup into a cascading outage.
Instead, use exponential backoff with jitter:
import time
import random
def fetch_with_retry(url, max_retries=3):
for attempt in range(max_retries):
try:
resp = requests.get(url, timeout=10)
if resp.status_code < 500:
resp.raise_for_status()
return resp
# Server error — retry
except (requests.Timeout, requests.ConnectionError):
pass # Network error — retry
if attempt < max_retries - 1:
sleep_time = (2 ** attempt) + random.uniform(0, 1)
time.sleep(sleep_time)
raise Exception(f"Failed after {max_retries} attempts: {url}")
The jitter (random component) prevents the thundering herd. Two services retrying at 1s, 2s, 4s in lockstep will collide again. Adding ±0.5s jitter spreads them out.
Step 3: Handle Status Codes Individually
A blanket raise_for_status() is better than nothing, but it treats 404 and 429 the same way. They are not the same.
class APIError(Exception):
def __init__(self, status_code, message, retry_after=None):
self.status_code = status_code
self.message = message
self.retry_after = retry_after
def handle_response(resp):
if resp.status_code == 429:
retry_after = resp.headers.get("Retry-After", "60")
raise APIError(429, "Rate limited", retry_after=int(retry_after))
if resp.status_code == 401:
raise APIError(401, "Authentication failed — refresh your token")
if resp.status_code == 403:
raise APIError(403, "Forbidden — check your permissions")
if resp.status_code >= 500:
raise APIError(resp.status_code, f"Upstream server error")
resp.raise_for_status()
return resp
Now your retry layer can be smart: respect Retry-After headers for 429s, retry on 5xx only, and bail immediately on 4xx client errors (except 429).
Step 4: Degrade Gracefully, Don't Crash
Not every API call is mission-critical. If the weather widget fails, users should still see their dashboard — just without the weather.
def get_dashboard_data(user_id):
data = {
"user": fetch_user(user_id),
"orders": fetch_orders(user_id),
}
try:
data["weather"] = fetch_weather(user_id)
except Exception:
data["weather"] = {"status": "unavailable"}
# Log it, but don't block the entire response
return data
The key insight: degrade by feature, not by the whole page. Users won't notice a missing weather widget. They will absolutely notice a blank page.
Step 5: Monitor What Breaks
Error handling without monitoring is like a smoke detector with no battery. You need to see the failures to fix them.
At minimum, track these three things:
- Error rate by endpoint and status code — so you know which upstream is flaking
- Retry success rate — if retries are consistently failing, the backoff strategy needs tuning
- P95 latency of downstream calls — degradation often starts as slowness, not errors
A simple logging approach:
import logging
import time
logger = logging.getLogger("api-client")
def instrumented_call(fn, name, *args, **kwargs):
start = time.monotonic()
try:
result = fn(*args, **kwargs)
elapsed = time.monotonic() - start
logger.info("api_call", extra={
"name": name, "status": "ok", "elapsed_ms": round(elapsed * 1000)
})
return result
except APIError as e:
elapsed = time.monotonic() - start
logger.error("api_call", extra={
"name": name, "status": e.status_code, "elapsed_ms": round(elapsed * 1000)
})
raise
Feed these logs into your existing monitoring stack (Datadog, Grafana, whatever you use). Set alerts on error rate spikes. You'll catch problems before your users do.
What I Wish I'd Known Earlier
The biggest lesson from that Friday night outage wasn't technical. It was this: error handling is not a nice-to-have. It's not "we'll add it later." It's as fundamental as the happy path itself.
Every external API — every single one — will fail at some point. Your customers don't care whose fault it is. They just see a broken page.
The five steps above aren't groundbreaking. They're battle-tested patterns that take maybe an afternoon to implement. That afternoon buys you sleep on the nights when things inevitably go wrong.
Start with Step 1 today. Map your external dependencies. For each one, ask: "What happens when this breaks?" If the answer is "I don't know," you've found your next task.
Have a different approach to API error handling? I'd love to hear it in the comments.
Top comments (0)