DEV Community

Sir Max
Sir Max

Posted on

How I Stopped Getting Paged at 3 AM: Building Resilient API Clients

The 3 AM Phone Call

Two years ago, I woke up to my phone buzzing at 3:14 AM. Our payment processing API was down — again. The third-party provider had another "unexpected outage," and our system was retrying requests in a tight loop, hammering their already-dead endpoint while our users saw nothing but spinning wheels.

That night cost us 47 abandoned carts and a very angry customer support ticket that started with "I tried 11 times and..."

I swore I'd never get that call again. Here's what I built instead — and what you should build too if your application depends on external APIs.


The Problem: Most API Clients Are Optimistic to a Fault

The default HTTP client in most languages does exactly one thing when a request fails: nothing useful. No retry. No backoff. No circuit breaker. Just a stack trace and a confused user.

Here's what a naive API call looks like in most codebases:

import requests

def charge_customer(amount, token):
    resp = requests.post("https://api.paymentprovider.com/v1/charges", json={
        "amount": amount,
        "token": token
    })
    return resp.json()
Enter fullscreen mode Exit fullscreen mode

This function has three failure modes, and it handles exactly zero of them:

Failure What Happens User Sees
Timeout (API hangs) requests waits forever (default: no timeout) Infinite spinner
5xx error Exception propagates up "Something went wrong"
Network blip (transient) Instant failure, no retry Frustrating "try again"

Layer 1: Timeouts (The One-Liner That Saves You)

The single highest-ROI change you can make:

# Before: no timeout — can hang forever
resp = requests.post(url, json=payload)

# After: connect timeout + read timeout
resp = requests.post(url, json=payload, timeout=(3.0, 10.0))
Enter fullscreen mode Exit fullscreen mode

The tuple is (connect_timeout, read_timeout). Connect timeout handles DNS/network delays. Read timeout handles slow responses. If your API hasn't sent bytes in 10 seconds, something is wrong — fail fast so your retry logic can kick in.

Real numbers from our system: Adding timeouts reduced average failure recovery time from 45 seconds to 6 seconds. Users stopped rage-quitting during upstream outages.


Layer 2: Retry With Exponential Backoff and Jitter

Not all failures are permanent. Network blips, DNS hiccups, and load balancer reboots last seconds. A retry with the right backoff strategy catches these.

import time
import random

def retry_with_backoff(func, max_retries=3, base_delay=1.0):
    for attempt in range(max_retries + 1):
        try:
            return func()
        except (ConnectionError, Timeout, ServerError) as e:
            if attempt == max_retries:
                raise
            # Exponential backoff with jitter
            delay = base_delay * (2 ** attempt)
            jitter = random.uniform(0, delay * 0.3)
            time.sleep(delay + jitter)
Enter fullscreen mode Exit fullscreen mode

Why jitter matters: If 10,000 clients all retry at exactly 1s, 2s, 4s, you create thundering herds that can knock over a recovering service. Jitter spreads the load. AWS has a great writeup on this in their architecture blog — their internal services all use jittered backoff.

The retry delays look like this:

  • Attempt 1: ~1.0-1.3 seconds
  • Attempt 2: ~2.0-2.6 seconds
  • Attempt 3: ~4.0-5.2 seconds

Total: ~7-9 seconds before giving up. Fast enough that users notice the delay, slow enough to catch most transient failures.


Layer 3: Circuit Breaker (Stop Hitting a Dead Horse)

Retry with backoff works for transient failures. But when a service is down for 30 minutes, retrying every 9 seconds means 200 failed requests per endpoint — wasting resources and potentially making things worse.

Enter the circuit breaker pattern:

import threading
import time

class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=30):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.failures = 0
        self.last_failure_time = 0
        self.state = "CLOSED"  # CLOSED, OPEN, HALF_OPEN
        self.lock = threading.Lock()

    def call(self, func, fallback=None):
        with self.lock:
            if self.state == "OPEN":
                if time.time() - self.last_failure_time > self.recovery_timeout:
                    self.state = "HALF_OPEN"
                else:
                    return fallback() if fallback else None

        try:
            result = func()
            with self.lock:
                self.failures = 0
                self.state = "CLOSED"
            return result
        except Exception:
            with self.lock:
                self.failures += 1
                self.last_failure_time = time.time()
                if self.failures >= self.failure_threshold:
                    self.state = "OPEN"
            raise

# Usage
breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30)

def get_product_price(product_id):
    return breaker.call(
        lambda: requests.get(f"https://api.pricing.com/v1/products/{product_id}", timeout=5),
        fallback=lambda: {"price": get_cached_price(product_id)}
    )
Enter fullscreen mode Exit fullscreen mode

Three states:

  • CLOSED: Normal operation. Requests flow through.
  • OPEN: Circuit is tripped. All requests immediately return the fallback (or fail fast). No requests reach the downstream service.
  • HALF_OPEN: After the recovery timeout, one request is allowed through as a probe. If it succeeds, the circuit closes. If it fails, it opens again.

This one pattern eliminated our 3 AM pages. When the payment provider went down, the circuit opened within 5 failures (~45 seconds), and from that point on, every request returned a "payment temporarily unavailable — we'll notify you when it's back" message instead of spinning forever. Support tickets about payment failures dropped from "I tried 11 times" to "I got a clear message and an email when it was back."


Layer 4: Graceful Degradation (The Fallback Chain)

Not every API call is critical. If your product recommendation engine is down, showing a static "popular products" list is better than a blank page.

Build fallback chains:

def get_recommendations(user_id):
    # Tier 1: Personalized ML model
    try:
        return call_recommendation_api(user_id)
    except Exception:
        pass

    # Tier 2: Cached recent recommendations
    cached = get_cached_recommendations(user_id)
    if cached:
        return cached

    # Tier 3: Static popular products
    return get_popular_products(category="default")
Enter fullscreen mode Exit fullscreen mode

Map your API dependencies on a criticality spectrum:

Criticality Examples Strategy
Critical (must succeed) Payment processing, auth Retry + circuit breaker + alerting
Important (can degrade) Product search, recommendations Fallback chain + stale cache
Nice-to-have (optional) Analytics, logging, A/B tests Fire-and-forget, async queue

Putting It All Together: A Production-Ready API Client

Here's what a resilient API call looks like after all four layers:

from dataclasses import dataclass
from typing import Optional, Callable
import requests
import time
import random
import threading

@dataclass
class ResilientClient:
    timeout: tuple = (3.0, 10.0)
    max_retries: int = 3
    base_delay: float = 1.0
    circuit_threshold: int = 5
    circuit_recovery: int = 30

    def __post_init__(self):
        self._breakers = {}

    def _get_breaker(self, key: str) -> CircuitBreaker:
        if key not in self._breakers:
            self._breakers[key] = CircuitBreaker(
                failure_threshold=self.circuit_threshold,
                recovery_timeout=self.circuit_recovery
            )
        return self._breakers[key]

    def call(self, method: str, url: str, *,
             fallback: Optional[Callable] = None,
             breaker_key: Optional[str] = None,
             **kwargs) -> requests.Response:

        breaker_key = breaker_key or f"{method}:{url}"
        breaker = self._get_breaker(breaker_key)

        def make_request():
            return requests.request(
                method, url, timeout=self.timeout, **kwargs
            )

        def attempt_with_retry():
            for attempt in range(self.max_retries + 1):
                try:
                    return make_request()
                except (requests.ConnectionError, requests.Timeout) as e:
                    if attempt == self.max_retries:
                        raise
                    delay = self.base_delay * (2 ** attempt)
                    jitter = random.uniform(0, delay * 0.3)
                    time.sleep(delay + jitter)
            raise RuntimeError("unreachable")

        return breaker.call(attempt_with_retry, fallback=fallback)


# Usage
client = ResilientClient()

try:
    resp = client.call("POST", "https://api.payment.com/v1/charges",
                       json={"amount": 1999, "token": "tok_xxx"},
                       fallback=lambda: queue_for_retry(amount=1999))
except Exception:
    # Circuit is open and no fallback — show user a clear message
    show_message("Payment is temporarily unavailable. We'll retry automatically.")
Enter fullscreen mode Exit fullscreen mode

The Results After 6 Months

Since implementing these four layers across our 40+ external API dependencies:

  • 3 AM pages: Zero. Down from 2-3 per month.
  • Failed payment recovery: 94% of transient failures now resolve automatically via retry (was ~30% with naive retry).
  • User-facing error rate: Down 82%. Users see fallback content or clear messages instead of blank screens.
  • Upstream load during outages: Down 96%. Circuit breakers stop the barrage.

The best part? Most of this is 50 lines of code. The CircuitBreaker class is 30 lines. The retry decorator is 15 lines. Timeouts are one parameter.


What I'd Do Differently

  1. Start with timeouts, not retries. I built an elaborate retry system first, then realized I had no timeout set — so retries were waiting for the default infinity-second timeout before even starting. Backwards.

  2. Log every circuit trip. When a circuit opens, log it with the endpoint, failure count, and timestamp. You'll discover patterns — like "the inventory API always goes down Tuesdays at 2 AM during their maintenance window."

  3. Circuit breakers need per-endpoint granularity, not per-service.
    POST /charges
    failing doesn't mean GET /products will fail. Key your breakers by endpoint, not by hostname.

  4. Test your fallbacks. Easy to write a fallback function, easy to forget it breaks when the upstream API changes its response format. Run chaos engineering tests monthly.


TL;DR

Four layers, from simplest to most impactful:

  1. Timeouts — 1 line of code, prevents infinite hangs
  2. Retry with jittered backoff — 15 lines, catches transient failures
  3. Circuit breaker — 30 lines, stops hammering dead services
  4. Fallback chain — Business logic, keeps users happy during outages

Build these before you need them. Because at 3 AM, you don't want to be the person debugging a retry loop.


What's your approach to handling API failures? I'd love to hear what patterns have worked for you — drop a comment below.

Top comments (0)