πŸ›‘οΈ Resilience Patterns Under Load

At scale, dependencies will fail β€” slow down, time out, throw. The goal isn't zero failures; it's to degrade gracefully instead of collapsing.

The Core Problem: One Slow Dependency Takes Everything Down

Scenario: A downstream API that normally answers in 20ms starts taking 30s. Your service has no timeout. Each request now holds its worker (or connection, or coroutine) for 30s. Within seconds every worker is stuck waiting, the pool is exhausted, healthy requests queue behind the sick ones β€” and your whole service goes down because one dependency got slow. That's a cascading failure.
Resilience is three moves stacked: timeouts so you give up on a stuck call, retries with backoff+jitter so a transient blip recovers without a stampede, and a circuit breaker so you stop hammering something that's clearly down. Then shed load when even that isn't enough.

Timeouts Everywhere β€” the #1 Fix

A missing timeout is the single most common cause of cascading hangs. The default for most clients is "wait forever," which is exactly what you must never do under load. Every network call gets a per-request timeout β€” no exceptions.

In Threading β€” requests / httpx

import requests, httpx

# requests: ALWAYS pass a timeout. Without it, requests waits indefinitely.
# Prefer a (connect, read) tuple so a slow body can't hang forever.
requests.get(url, timeout=(3.05, 10))   # 3.05s to connect, 10s to read

# httpx: granular, and you can set a default on the client:
client = httpx.Client(timeout=httpx.Timeout(10.0, connect=3.0))
client.get(url)

In Asyncio β€” asyncio.timeout() (3.11+)

import asyncio, httpx

async def fetch(client, url):
    # Wraps ANY awaitable in a deadline β€” cleaner than wait_for, cancels on exit.
    async with asyncio.timeout(5):          # Python 3.11+
        return await client.get(url)

# Pre-3.11 equivalent:
async def fetch_old(client, url):
    return await asyncio.wait_for(client.get(url), timeout=5)

In a Thread Pool β€” future.result(timeout=)

from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout

with ThreadPoolExecutor(max_workers=10) as pool:
    fut = pool.submit(requests.get, url, timeout=10)
    try:
        resp = fut.result(timeout=12)   # caller-side deadline (slightly > the request timeout)
    except FutureTimeout:
        resp = None   # NOTE: the worker thread keeps running β€” the underlying call MUST
                      # also have its own timeout, or you leak a stuck thread.
Gotcha: future.result(timeout=) only unblocks the caller; it does not kill the worker. The blocking call itself still needs a real timeout, or you leak threads (see pool exhaustion). Set the client timeout tighter than the future timeout.

Retries with Exponential Backoff + Jitter

Transient failures (a dropped connection, a brief 503) deserve a retry. But naive retries are dangerous: a fixed delay makes every client retry in lockstep, producing synchronized traffic spikes that keep a recovering service down. This is a retry storm.

The retry storm: the dependency hiccups β†’ 10,000 clients all fail at the same instant β†’ all sleep exactly 1s β†’ all retry at the same instant β†’ a 10,000-request spike lands on the barely-alive service β†’ it falls over again. Fixed backoff synchronizes the herd. Jitter desynchronizes it.
import time, random

def call_with_backoff(fn, *, max_attempts=5, base=0.2, cap=10.0):
    """Exponential backoff with FULL jitter: sleep = random(0, min(cap, base * 2**attempt))."""
    for attempt in range(max_attempts):
        try:
            return fn()
        except (ConnectionError, TimeoutError) as exc:   # only RETRYABLE errors
            if attempt == max_attempts - 1:
                raise
            expo = min(cap, base * (2 ** attempt))        # 0.2, 0.4, 0.8, 1.6, ...
            sleep = random.uniform(0, expo)               # FULL jitter spreads the herd
            time.sleep(sleep)

# Decorrelated jitter (AWS): each sleep grows off the PREVIOUS one, bounded by cap.
def decorrelated_sleep(prev, base=0.2, cap=10.0):
    return min(cap, random.uniform(base, prev * 3))

In Asyncio β€” non-blocking backoff

import asyncio, random

async def call_with_backoff_async(coro_factory, *, max_attempts=5, base=0.2, cap=10.0):
    for attempt in range(max_attempts):
        try:
            return await coro_factory()          # a fresh awaitable each attempt
        except (ConnectionError, TimeoutError, asyncio.TimeoutError):
            if attempt == max_attempts - 1:
                raise
            expo = min(cap, base * (2 ** attempt))
            await asyncio.sleep(random.uniform(0, expo))   # await, don't block the loop
Retry budget + idempotency caveat: only retry idempotent operations (GET, PUT, DELETE) β€” retrying a non-idempotent POST can double-charge or double-post. And cap total retries across the fleet with a retry budget (e.g. "retries ≀ 10% of requests"); otherwise retries alone can triple the load on a struggling dependency.

Circuit Breaker β€” Stop Hammering What's Already Down

Retries help with transient failures. But when a dependency is genuinely down, retrying just wastes resources and slows recovery. A circuit breaker tracks the failure rate and, once it crosses a threshold, trips open β€” failing fast without even attempting the call β€” then periodically probes to see if the dependency has recovered.

stateDiagram-v2 [*] --> Closed Closed --> Open: failures β‰₯ threshold Open --> HalfOpen: cooldown elapsed HalfOpen --> Closed: probe succeeds HalfOpen --> Open: probe fails note right of Closed calls pass through
count failures end note note right of Open fail fast, no calls
wait for cooldown end note
import time, threading

class CircuitOpenError(Exception):
    pass

class CircuitBreaker:
    """Thread-safe closed -> open -> half-open breaker."""
    def __init__(self, fail_threshold=5, cooldown=30.0):
        self._fail_threshold = fail_threshold
        self._cooldown = cooldown
        self._failures = 0
        self._state = "closed"          # closed | open | half_open
        self._opened_at = 0.0
        self._lock = threading.Lock()

    def call(self, fn, *args, **kwargs):
        with self._lock:
            if self._state == "open":
                if time.monotonic() - self._opened_at >= self._cooldown:
                    self._state = "half_open"      # time to probe
                else:
                    raise CircuitOpenError("circuit open β€” failing fast")
        try:
            result = fn(*args, **kwargs)
        except Exception:
            self._on_failure()
            raise
        else:
            self._on_success()
            return result

    def _on_success(self):
        with self._lock:                # a good call closes the circuit and resets
            self._failures = 0
            self._state = "closed"

    def _on_failure(self):
        with self._lock:
            self._failures += 1
            # a failed probe in half_open, or crossing the threshold, trips it open
            if self._state == "half_open" or self._failures >= self._fail_threshold:
                self._state = "open"
                self._opened_at = time.monotonic()

# Usage:
breaker = CircuitBreaker(fail_threshold=5, cooldown=30)
try:
    resp = breaker.call(requests.get, url, timeout=5)
except CircuitOpenError:
    resp = get_from_cache_or_default()   # degrade gracefully instead of hanging
Fail fast beats fail slow. An open breaker returns in microseconds, so your workers stay free for healthy traffic instead of piling up on a dead dependency. That's the difference between "feature X is degraded" and "the whole service is down."

Load Shedding & Deadline Propagation

Timeouts, retries, and breakers protect you from downstream failure. Load shedding protects you from your own overload: when you're already saturated, accepting more work makes everything slower and helps no one. Reject early β€” return 503 immediately when the queue is full β€” so the requests you do accept get served fast.

import threading

class Overloaded(Exception):
    pass

# Admission control: bound in-flight work; reject (503) the moment we're full.
class LoadShedder:
    def __init__(self, max_in_flight):
        self._sem = threading.BoundedSemaphore(max_in_flight)

    def __enter__(self):
        if not self._sem.acquire(blocking=False):   # DON'T queue β€” reject now
            raise Overloaded("shedding load: at capacity")
        return self

    def __exit__(self, *exc):
        self._sem.release()

shedder = LoadShedder(max_in_flight=200)

def handle(request):
    try:
        with shedder:
            return process(request)
    except Overloaded:
        return Response(status=503, headers={"Retry-After": "1"})   # fail fast, tell client to back off
Deadline propagation: pass the request's remaining time budget down the call chain. If the client's 500ms deadline is already blown by the time a request reaches your DB call, don't make the call β€” it's wasted work whose result nobody will read. Shed it instead. See backpressure for the queue-bounding side of this.
import time

def with_deadline(deadline_monotonic, fn, *args, **kwargs):
    remaining = deadline_monotonic - time.monotonic()
    if remaining <= 0:
        raise Overloaded("deadline exceeded before call β€” shedding")
    # Never let a downstream call outlive the caller's deadline:
    return fn(*args, timeout=remaining, **kwargs)

Failure Mode β†’ Pattern

Failure modePatternWhy
Dependency hangs / slowTimeoutGive up a stuck call before it exhausts your pool
Transient blip (dropped conn, 503)Retry + backoff + jitterRecover without synchronizing a stampede
Dependency fully downCircuit breakerFail fast, stop wasting workers, let it recover
You are overloadedLoad shedding (503)Serve accepted work fast instead of everything slowly
Work already past its budgetDeadline propagationDon't do work whose result nobody will read
Queue filling faster than drainBackpressurePush back on the producer before OOM

Combine Them β€” They Only Work Together

No single pattern is sufficient. A timeout without a breaker still lets you hammer a dead service every request. A breaker without timeouts never trips because calls hang forever. Retries without jitter cause storms. Layer them, innermost to outermost:

# Order of composition (inside -> out):
#   1. timeout      β€” bound each individual attempt
#   2. retry+jitter β€” recover transient failures (idempotent only)
#   3. breaker      β€” after repeated failure, stop trying entirely
#   4. shed/deadlineβ€” at the edge, reject when overloaded

breaker = CircuitBreaker(fail_threshold=5, cooldown=30)

def resilient_get(url, deadline):
    def attempt():
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise Overloaded("deadline exceeded")
        return breaker.call(requests.get, url, timeout=min(remaining, 5))  # timeout + breaker

    return call_with_backoff(attempt, max_attempts=3)                      # + retry/jitter
flowchart LR REQ["request"] --> SHED{"overloaded?"} SHED -->|yes| R503["503 fast"] SHED -->|no| CB{"breaker open?"} CB -->|yes| FAIL["fail fast
/ fallback"] CB -->|no| TRY["call w/ timeout"] TRY -->|fail| RETRY["retry
backoff + jitter"] RETRY --> TRY TRY -->|ok| OK["βœ… response"]
Resilience checklist: β‘  timeout on every network call β‘‘ retry only idempotent ops, with backoff + jitter + a budget β‘’ wrap flaky dependencies in a circuit breaker β‘£ shed load with 503 when saturated β‘€ propagate deadlines so no one does dead work β‘₯ combine all of the above. Failures are inevitable; collapse is optional.