DEV Community

Sir Max
Sir Max

Posted on

The Hidden Cost of Not Handling API Errors Properly (And How to Fix It in 5 Steps)

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)