Filter concepts by levelShowing all levels.

Python · API Reliability

API Reliability

Timeouts, retries with backoff and jitter, circuit breakers, bulkheads, and graceful degradation — the patterns that keep a caller healthy when a downstream is not

  • Bound every callno timeout means "wait forever" — a hung downstream hangs you too
  • Backoff + jitterwait longer each retry, randomized — or every client retries in lockstep
  • Fail fast when opena circuit breaker stops calling a known-failing downstream entirely
  • Degrade, don't breaka fallback keeps the request succeeding with a worse-but-working result

1.Timeouts

A timeout is the maximum time you let a call to another service run before giving up and raising an error yourself. Without one, a hung downstream service hangs your service too — forever, not just slowly.

Key features

  • Every network call needs an explicit timeout — the default in most Python HTTP clients is "no timeout" (wait forever)
  • A timeout that fires raises an exception (socket.timeout, httpx.TimeoutException) — it does not return a partial or empty result silently
  • Connect timeout (time to establish the connection) and read timeout (time to receive a response) are separate settings — set both
  • A timeout that is too short turns healthy-but-slow calls into false failures; too long defeats the point of having one at all
  • A timeout bounds ONE attempt — it says nothing about whether to try again, which is what retries decide

Timeout knobs on Python's common HTTP clients

Timeout knobs on Python's common HTTP clients
ClientSettingBehavior when it fires
socket.socketsock.settimeout(seconds)raises socket.timeout on connect or recv
httpx.Clienthttpx.Client(timeout=5.0)raises httpx.TimeoutException
httpx (split)httpx.Timeout(connect=2.0, read=5.0)each phase gets its own budget
requestsrequests.get(url, timeout=5)raises requests.exceptions.Timeout
A timeout turns silence into a decision point

Call starts

deadline set, e.g. 5s

No response by deadline

downstream may be alive or dead — unknown

Timeout fires

raises an exception NOW, not eventually

  1. Call starts — deadline set, e.g. 5s
  2. No response by deadline — downstream may be alive or dead — unknown
  3. Timeout fires — raises an exception NOW, not eventually

Command

python
import httpx

with httpx.Client(timeout=5.0) as client:
    try:
        response = client.get(url)
    except httpx.TimeoutException:
        # decide here: retry, fall back, or surface an error
        ...

Every network call needs an explicit timeout — no timeout means "wait forever," and a hung downstream then hangs you too.

2.Retries, exponential backoff, and jitter

A retry runs a failed call again instead of giving up immediately. Exponential backoff waits longer between each attempt (1s, 2s, 4s...) so a struggling service gets room to recover instead of being hit harder. Jitter adds a random amount to that wait so many clients retrying at once do not all hit the service at the exact same moment.

Key features

  • Retry only errors that are plausibly transient (timeouts, connection resets, 502/503/504) — never a 400 or 422, which will fail identically every time
  • Exponential backoff multiplies the wait each attempt (e.g. base * 2^attempt), capped at a maximum so it does not grow unbounded
  • Jitter picks a random value UP TO the backoff ceiling (or randomizes within a window around it) — it does not replace backoff, it varies it
  • A retry loop needs a maximum attempt count or maximum total time — an uncapped loop against a permanently dead service retries forever
  • Retrying a non-idempotent operation (like charging a card) without an idempotency key can repeat its side effect — see the idempotency concept

What each layer of a retry strategy adds

What each layer of a retry strategy adds
LayerAddsWithout it
Retrytries the call again after a transient failureone blip permanently fails the whole operation
Exponential backoffwaits longer each attempt (1s, 2s, 4s, 8s...)every retry hits the struggling service at full speed immediately
Capceilings the wait (e.g. never more than 30s)the delay grows unbounded on a long outage
Jitterrandomizes the wait within that ceilingevery client retries in lockstep, recreating the original spike
Max attemptsgives up after N tries and surfaces an errora dead service is retried forever, hiding the failure from the caller
Each failed attempt waits longer, by a randomized amount
yesretryno

Call fails

Attempts left?

Wait (backoff × jitter)

Raise to caller

  • Call fails
    • leads to Attempts left?
  • Attempts left?
    • leads to Wait (backoff × jitter) (yes)
    • leads to Raise to caller (no)
  • Wait (backoff × jitter)
    • leads to Call fails (retry)
  • Raise to caller

Command

python
import random, time

def retry_with_backoff_and_jitter(fn, max_attempts=5, base=1.0, cap=30.0):
    for attempt in range(1, max_attempts + 1):
        try:
            return fn()
        except TRANSIENT_ERRORS as exc:
            if attempt == max_attempts:
                raise
            ceiling = min(cap, base * (2 ** (attempt - 1)))
            time.sleep(random.uniform(0, ceiling))

Retry only transient failures, wait longer each time (exponential backoff), and randomize that wait (jitter) — or every client retries in the same synchronized wave.

3.Circuit breakers

A circuit breaker tracks a downstream call's recent failures. After too many in a row, it "opens" and rejects further calls immediately — without even trying — for a cooldown period, then lets one test call through to check if the downstream has recovered.

Key features

  • Closed is the normal state — calls go through, and the breaker counts consecutive (or windowed) failures
  • Open means calls are rejected immediately, without even attempting the downstream — this is what makes a breaker faster than a timeout under sustained failure
  • After a reset_timeout, an open breaker moves to half-open and allows exactly one trial call through
  • A successful half-open call closes the breaker (failure count resets); a failed one reopens it and restarts the cooldown
  • A breaker protects the CALLER from wasting time and resources on a downstream that is known to be failing — it does not fix the downstream itself

Circuit breaker states

Circuit breaker states
StateCalls allowed?Transitions to
closedyes, all of themopen, once failure_count reaches the threshold
openno — rejected immediately, no downstream call madehalf-open, once reset_timeout elapses
half-openyes, exactly one trial callclosed on success; open again on failure
Circuit breaker state machine
failures ≥thresholdreset_timeoutelapsestrial callsucceedstrial callfails

Closed (calls flow)

start

Open (rejects immediately)

Half-open (one trial call)

  • Closed (calls flow) (start)
    • → Open (rejects immediately) when failures ≥ threshold
  • Open (rejects immediately)
    • → Half-open (one trial call) when reset_timeout elapses
  • Half-open (one trial call)
    • → Closed (calls flow) when trial call succeeds
    • → Open (rejects immediately) when trial call fails

Command

python
class CircuitBreaker:
    def __init__(self, failure_threshold=5, reset_timeout=30.0):
        self.failure_threshold = failure_threshold
        self.reset_timeout = reset_timeout
        self.failure_count = 0
        self.state = "closed"
        self.opened_at = None

    def call(self, fn, *args, **kwargs):
        if self.state == "open":
            if time.monotonic() - self.opened_at >= self.reset_timeout:
                self.state = "half-open"
            else:
                raise CircuitOpenError("circuit is open")
        try:
            result = fn(*args, **kwargs)
        except Exception:
            self.failure_count += 1
            if self.state == "half-open" or self.failure_count >= self.failure_threshold:
                self.state, self.opened_at = "open", time.monotonic()
            raise
        else:
            self.failure_count, self.state = 0, "closed"
            return result

Closed calls flow; enough failures open it and calls are rejected immediately with no downstream call; after a cooldown, one half-open trial call decides whether to close again or reopen.

4.Rate limiting as a client-side reliability defense

A client can rate-limit ITSELF — capping how fast it sends requests to a downstream — so that when that downstream is struggling, your traffic does not push it further into failure. This is the same token-bucket idea a server uses to protect itself, applied on the calling side instead.

Key features

  • A struggling downstream often gets WORSE under retry storms — every failed call retried immediately multiplies load exactly when the service can least handle it
  • A client-side limiter caps outgoing call rate regardless of whether the downstream has told you to slow down yet
  • The same token-bucket shape works on either side: capacity for bursts, a steady refill rate for sustained throughput
  • Client-side limiting is a form of self-imposed backpressure — different from a circuit breaker, which stops calling entirely rather than just slowing down
  • A 429 response with Retry-After from the server should update your limiter's pace directly — it is the downstream telling you its actual budget

Command

python
import time

class ClientSideLimiter:
    def __init__(self, capacity, refill_rate):
        self.capacity, self.tokens = capacity, capacity
        self.refill_rate = refill_rate
        self.last = time.monotonic()

    def allow(self):
        now = time.monotonic()
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill_rate)
        self.last = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

A client can throttle its own calls to a struggling downstream before being told to — the same token-bucket shape as a server rate limit, applied on the calling side to avoid piling on.

5.Idempotency as what makes a retry safe

An operation is idempotent if running it twice has the same effect as running it once. Retrying a NON-idempotent operation (like "charge $10") repeats its side effect on every retry — retrying an idempotent one (or one made idempotent with a dedup key) is always safe.

Key features

  • GET, PUT, and DELETE are naturally idempotent by HTTP's own definition — repeating them produces the same end state as doing it once
  • POST is NOT naturally idempotent — "create an order" run twice creates two orders unless something explicitly prevents that
  • A retry loop (this section's own retries-backoff-and-jitter concept) is only safe to point at a non-idempotent operation if that operation has been made idempotent first
  • The standard fix is a dedup key: the caller generates an ID once, and the operation checks "have I already done this exact ID?" before acting again
  • This is the same idempotency-key MECHANISM api-development covers — the reliability lens is WHY it matters: it is the precondition, not an optional add-on, for retrying a write safely

Command

python
processed = {}

def charge_card(amount, dedup_key):
    if dedup_key in processed:
        return processed[dedup_key]     # already done — return the same result, don't repeat it
    charge_payment_provider(amount)
    processed[dedup_key] = "charged"
    return processed[dedup_key]

Retrying a write is only safe if the write is idempotent — a retry loop and a non-idempotent operation without a dedup key is a recipe for duplicated side effects.

6.Request validation and input limits

Request validation rejects a malformed or out-of-range request before it reaches business logic. Input limits are validation applied to SIZE — a maximum string length, list length, or payload size — so a request cannot exhaust memory or processing time just by being enormous.

Key features

  • Validate at the boundary, before business logic runs — a request that fails validation should never reach a database call or downstream service
  • A missing size limit is an availability risk, not just a data-quality one — an unbounded list or string lets one request consume memory proportional to its size
  • Reject with a specific, actionable error message per field — "customer_name exceeds 20 characters" is debuggable; "invalid request" is not
  • Limits belong on every unbounded input: string length, list/array length, nesting depth, and total payload size
  • A limit that is too low breaks legitimate use; too high (or absent) leaves the system exposed to a single oversized request degrading it for everyone

Command

python
MAX_ITEMS = 100
MAX_NAME_LEN = 200

def validate_order(payload):
    errors = []
    name = payload.get("customer_name", "")
    if not name or len(name) > MAX_NAME_LEN:
        errors.append(f"customer_name must be 1-{MAX_NAME_LEN} characters")
    items = payload.get("items", [])
    if not items or len(items) > MAX_ITEMS:
        errors.append(f"items must be 1-{MAX_ITEMS} entries")
    if errors:
        raise ValueError("; ".join(errors))
    return {"customer_name": name, "items": items}

Validate shape at the boundary AND cap size (string length, list length, payload size) — a request can be perfectly well-typed and still unbounded.

7.Connection pooling as part of the resilience picture

A connection pool is a fixed number of reusable connections, not an unlimited supply. What happens when every connection is already in use — block and wait, time out, or reject immediately — is a reliability decision every pooled resource needs, independent of the performance win pooling itself provides.

Key features

  • A pool has a maximum size — once every connection is checked out, the next request to acquire one has to wait, time out, or be rejected
  • An unbounded pool (or no pool at all — a new connection per request) defeats the reason pools exist and can exhaust the downstream's own connection limit under load
  • A blocking acquire with NO timeout on an exhausted pool just moves the "hung forever" problem from the network call to the pool itself
  • Sizing a pool too small under-uses available concurrency; too large can overwhelm the downstream service or database it connects to
  • Pool exhaustion under load is a signal worth treating like any other reliability failure — surfaced as an error and handled, not silently blocked on indefinitely

Command

python
class ConnectionPool:
    def __init__(self, size):
        self.size, self.in_use = size, 0

    def acquire(self):
        if self.in_use >= self.size:
            raise TimeoutError("pool exhausted; waited and gave up")
        self.in_use += 1
        return f"conn-{self.in_use}"

    def release(self):
        self.in_use = max(0, self.in_use - 1)

A pool is finite by design — decide upfront what happens when it is exhausted (fail fast with a timeout, not block forever) rather than discovering it under load.

8.Bulkheads

A bulkhead gives each downstream dependency (or feature) its own separate, limited pool of resources — threads, connections — instead of sharing one pool across everything. If one dependency saturates its own pool, the others keep working because they were never sharing it.

Key features

  • Without bulkheads, ALL downstream calls typically share one thread pool or connection pool — one slow dependency can consume the entire shared pool
  • A bulkhead gives each dependency its own dedicated pool sized for its own expected load, isolated from every other dependency's pool
  • A saturated bulkhead rejects (or queues) new calls to THAT dependency only — every other dependency, with its own separate pool, is unaffected
  • Bulkheads are a sizing/isolation decision made upfront, distinct from a circuit breaker (a runtime decision to stop calling a specific failing dependency)
  • The trade-off is real: isolated pools mean less resource sharing overall, so a healthy dependency cannot "borrow" idle capacity from another's pool

Command

python
class Bulkhead:
    def __init__(self, name, capacity):
        self.name, self.capacity, self.in_use = name, capacity, 0

    def run(self, fn):
        if self.in_use >= self.capacity:
            raise RuntimeError(f"{self.name} bulkhead full; rejecting immediately")
        self.in_use += 1
        try:
            return fn()
        finally:
            self.in_use -= 1

reports_pool = Bulkhead("reports", capacity=5)
checkout_pool = Bulkhead("checkout", capacity=20)   # separate, unaffected by reports

Give each dependency its own resource pool — a bulkhead — so one saturated dependency cannot starve every other feature sharing your service.

9.Graceful degradation and fallbacks

Graceful degradation means a failing dependency makes your response WORSE, not broken — a fallback is the specific worse-but-working substitute you return instead: cached data instead of live, a generic list instead of personalized, a default instead of a computed value.

Key features

  • Graceful degradation is a design goal: the SYSTEM keeps functioning, in a reduced way, instead of failing completely because one piece failed
  • A fallback is the concrete implementation — a specific alternate value or behavior returned in place of the one that could not be produced
  • A fallback must catch ONLY the specific failure types it is meant to handle (network/timeout errors from that dependency), never a bare except that also swallows real bugs
  • The best fallbacks degrade the LEAST important part of the response first — a stale cache beats a missing recommendation, which beats a broken page
  • A fallback path needs to be exercised (tested, or triggered in production occasionally) — an untested fallback is often broken exactly when it is finally needed

What degrades to what

What degrades to what
IdealDegraded fallbackWhy this is an acceptable trade
Live personalized recommendationsGeneric bestseller liststill useful; not personalized
Fresh database readLast-known cached valuepossibly stale, but not empty
Real-time inventory countLast-synced count, or "in stock"occasional oversell risk, acceptable at low volume
Full search with ranking servicePlain substring matchless relevant results, still functional
A failed dependency degrades the response, not the whole request

Call recommendation service

Fails (timeout/down)

Return fallback list

Response still succeeds

  • Call recommendation service
    • leads to Fails (timeout/down)
  • Fails (timeout/down)
    • leads to Return fallback list
  • Return fallback list
    • leads to Response still succeeds
  • Response still succeeds

Command

python
def get_recommendations():
    try:
        return get_recommendations_from_ml_service()
    except (ConnectionError, TimeoutError):
        return get_fallback_recommendations()   # degraded, but the page still works

A fallback catches only the specific failure it is meant to handle and substitutes a worse-but-working result — a bare except in a fallback hides real bugs as if they were outages.

Remember

  • Every network call needs an explicit timeout — no timeout means "wait forever."
  • Retry only transient failures with exponential backoff and jitter, and only if the operation is idempotent.
  • A circuit breaker rejects calls immediately once a downstream is known to be failing — faster than a timeout, and it stops piling on.
  • A fallback catches only the specific failure it is meant to handle — a bare except hides real bugs as if they were outages.

Pro tip

  • A client can rate-limit its OWN outgoing calls to a struggling downstream before being told to slow down with a 429.
  • Idempotency is the precondition for a safe retry — a dedup key, not the retry loop itself, is what prevents a duplicated charge.
  • Validate shape AND cap size — a request can be perfectly well-typed and still unbounded in length.
  • Give each dependency its own resource pool (a bulkhead) so one saturated dependency cannot starve every other feature.

Next up

  • Data Serialization

Sources

  • Martin Fowler, CircuitBreaker — martinfowler.com/bliki/CircuitBreaker.html
  • Azure Architecture Center, Bulkhead pattern — learn.microsoft.com/azure/architecture/patterns/bulkhead
  • httpx — python-httpx.org
  • Every example run for real on Python 3.14.3 · checked 2026-08-22

Spotted a mistake on this page? Tell us.