Filter concepts by levelShowing all levels.

Python · Production error handling

Production error handling

Deciding what to retry, how long to wait, what to log, and what a caller is actually allowed to see

  • Retryable vs non-retryabletransient condition vs a bad request — only one is worth retrying
  • Exponential backoffdelay doubles each retry, capped — 1s, 2s, 4s, 8s...
  • logger.exception(msg)message plus the full traceback, in one call
  • Never except Exception: passlog it at minimum — a swallowed failure looks like success

1.Exception boundaries

An exception boundary is the one deliberate place — an API handler, a job entry point — where every exception from the code it calls is caught and translated into one stable, intentional response, instead of leaking internal detail outward.

Key features

  • A boundary sits at a system edge — an API handler, a queue consumer, a CLI entry point
  • Code beneath the boundary raises specific, meaningful exceptions freely
  • The boundary catches broadly and translates into one stable response shape
  • Without a boundary, internal exception types and details leak to external callers
  • Only one boundary is needed per entry point — do not scatter broad catches throughout

Command

python
def api_boundary(payload):
    try:
        return call_external_api(payload)
    except ValueError as e:
        raise ExternalAPIError(f"upstream call failed: {e}") from e

Put one broad catch at the edge of a system — not scattered through internal code — and translate everything into a stable, intentional response there.

2.Error propagation

An exception with no matching except in the current function keeps traveling up the call stack, unchanged, to whichever caller has one — or crashes the program if none does. This is the default behaviour.

Key features

  • Propagation is automatic — a function with no try/except just lets a risky call propagate
  • Each calling function up the stack gets a chance to catch it before the next one
  • An exception reaching the top of the stack with no handler crashes the program
  • A function need not know or predict what its callers will do with a propagated exception
  • Catching too early, deep in a call chain, is often what propagating is the alternative to

Command

python
def parse_row(row):
    return int(row)   # no try/except — ValueError propagates to the caller

def parse_all(rows):
    return [parse_row(r) for r in rows]   # also does not catch — propagates further

An unhandled exception propagates automatically up the call stack. Catch where you can actually respond, not reflexively at every level.

3.Retryable errors

A retryable error comes from a transient condition — a dropped connection, a rate limit, a momentary timeout — where trying the exact same call again has a real chance of succeeding.

Key features

  • The condition causing the failure is expected to change on its own, without code changes
  • ConnectionError, TimeoutError, and a 429/503 HTTP response are classic retryable cases
  • Retrying only makes sense if the operation is safe to repeat — see idempotency
  • A fixed set of retryable types should be defined once, not guessed per call site
  • Retrying blindly on every exception type risks retrying a non-retryable error too

Command

python
RETRYABLE = (ConnectionError, TimeoutError)

def classify(exc):
    return "retry" if isinstance(exc, RETRYABLE) else "fail-fast"

A retryable error comes from a transient condition expected to change. Define the retryable set once, as a fixed tuple.

4.Non-retryable errors

A non-retryable error is caused by the request itself, not the moment — bad input, a missing permission, a malformed URL. Retrying the identical call cannot succeed, because nothing about the underlying problem changes on its own.

Key features

  • The cause is the request itself — bad input, invalid credentials, a malformed query
  • ValueError, a 400/401/403/404 HTTP response are classic non-retryable cases
  • Retrying wastes time and resources without any chance of a different outcome
  • Fail fast instead: surface the error immediately rather than delaying with a retry loop
  • Both retryable and non-retryable sets should be explicit, not inferred from any exception

Command

python
NON_RETRYABLE = (ValueError, InvalidRequestError)

def classify(exc):
    if isinstance(exc, NON_RETRYABLE):
        return "fail-fast"   # retrying the same call cannot help

A non-retryable error comes from the request itself — retrying the identical call cannot succeed. Fail fast instead of looping.

5.Timeouts

A timeout bounds how long an operation is allowed to run before it is treated as failed and TimeoutError is raised — without one, a caller can wait forever on a network call, lock, or process that never returns.

Key features

  • Without a timeout, a hung call can block a program indefinitely
  • A timeout converts "unknown, unbounded wait" into a definite TimeoutError
  • requests, socket, and most database clients accept a timeout= parameter directly
  • A timeout is a classic retryable error — the call might succeed within budget next time
  • Set timeouts on every external call by default, not only after a real incident

Command

python
import requests

try:
    response = requests.get(url, timeout=5)   # raises requests.Timeout past 5 seconds
except requests.Timeout:
    handle_timeout()

Set a timeout on every external call — without one, a hung dependency can block your program indefinitely instead of failing predictably.

6.Retry strategies

A retry strategy is a loop with a capped number of attempts, a delay between them, and a final re-raise if every attempt fails — never an unbounded while True around a call that keeps failing.

Key features

  • A retry loop needs a hard cap on attempts — never an unbounded loop
  • Only retry exceptions already classified as retryable — see retryable errors
  • The last failure should be re-raised, not silently discarded, once attempts run out
  • A fixed delay is the simplest strategy; exponential backoff improves on it
  • Retrying only makes sense for an operation safe to repeat (idempotent)

Command

python
def retry(fn, attempts, delay):
    last_exc = None
    for attempt in range(1, attempts + 1):
        try:
            return fn()
        except RETRYABLE as e:
            last_exc = e
            if attempt < attempts:
                time.sleep(delay)
    raise last_exc

Cap the attempt count, retry only classified-retryable exceptions, and re-raise the final failure once exhausted.

7.Exponential backoff

Exponential backoff doubles the delay before each retry — 1s, 2s, 4s, 8s — capped at a maximum, so retries spread out over time instead of hammering a struggling dependency at a constant rate.

Key features

  • delay = base * (2 ** (attempt-1)), capped — 1, 2, 4, 8, 16, 30, 30 for base=1, cap=30
  • The cap matters — uncapped doubling reaches minutes after only a handful of attempts
  • Jitter (randomizing within the delay) avoids many clients retrying in lockstep at once
  • Backoff only helps for retryable errors — see retryable errors
  • Combine with a capped attempt count — backoff alone does not bound total retries

Backoff delay by attempt — base=1.0s, cap=30.0s

Backoff delay by attempt — base=1.0s, cap=30.0s
AttemptUncapped (base × 2^(n−1))Actual delay (capped)
11.01.0
22.02.0
34.04.0
48.08.0
516.016.0
632.030.0
764.030.0
Delay doubles each retry, then caps

1s → 2s → 4s

delay doubles after each failed attempt

8s → 16s

keeps doubling while under the cap

30s (capped)

stops growing once it reaches the maximum

  1. 1s → 2s → 4s — delay doubles after each failed attempt
  2. 8s → 16s — keeps doubling while under the cap
  3. 30s (capped) — stops growing once it reaches the maximum

Command

python
def compute_backoff(base, attempt, cap):
    delay = base * (2 ** (attempt - 1))   # attempt is 1-indexed
    return min(delay, cap)

Double the delay each retry — base × 2^(attempt−1) — and always cap it. Add jitter so clients do not all resume at once.

8.Logging exceptions

logger.exception(message), called from inside an except block, logs the message at ERROR level and attaches the full traceback automatically — the same as logger.error(message, exc_info=True), but shorter to write.

Key features

  • logger.exception(msg) only makes sense inside except — it reads the active exception
  • It logs at ERROR level and appends the full traceback automatically
  • Equivalent to logger.error(msg, exc_info=True), just shorter
  • print(e) loses the traceback entirely — only the message string survives
  • Log once, where the exception is handled — not at every level it merely passes through

Command

python
try:
    risky_operation()
except ValueError:
    logger.exception("risky_operation failed")   # message + full traceback

Call logger.exception(message) from inside except to log the message and the full traceback together — f"{e}" alone throws the traceback away.

9.User-facing vs internal errors

Log the full, detailed exception internally — stack trace, table names, ids — and return a short, generic message to the caller. The same detail exposed externally leaks implementation.

Key features

  • Internal (logged): full exception, stack trace, ids, table names — as detailed as needed
  • External (returned/shown): short, generic, honest — no internals, no stack trace
  • Exposing internal detail externally is an information-leak risk, not just an aesthetic one
  • The two messages are built at the exception boundary — see exception boundaries
  • A generic external message still needs to be genuinely helpful, not just vague

Command

python
except OrderError as e:
    logger.error("order failed: %s", e)                # detailed, internal
    return {"ok": False, "error": "We could not process your order."}  # generic, external

Log the full, detailed exception internally; return a short, generic, honest message externally. Detail exposed externally is a leak.

10.Avoiding swallowed exceptions

A swallowed exception is caught and discarded with no trace at all — except Exception: pass. The failure still happened, but nothing logs it, so it looks to everyone else like the operation succeeded.

Key features

  • except Exception: pass discards the exception with zero trace — no log, no re-raise
  • The operation LOOKS like it succeeded to anything watching from outside
  • At minimum, log the exception even if the code truly cannot handle it usefully
  • A narrower except type plus a comment explaining WHY is the justified exception
  • Swallowing differs from handling — handling responds to a failure; swallowing does nothing
Swallowed vs. handled

exception raised

something genuinely went wrong

except Exception: pass

discarded — zero trace anywhere

looks like success

caller, logs, and metrics all see nothing

  1. exception raised — something genuinely went wrong
  2. except Exception: pass — discarded — zero trace anywhere
  3. looks like success — caller, logs, and metrics all see nothing

Command

python
try:
    risky_call()
except Exception:
    pass   # ANTI-PATTERN — no log, no metric, no trace this ever happened

except Exception: pass discards a real failure with zero trace. At minimum, log it, and catch the narrowest type you actually expect.

Remember

  • A retryable error is caused by a transient condition; a non-retryable error is caused by the request itself.
  • delay = min(base * 2 ** (attempt - 1), cap) — always cap exponential backoff, and add jitter.
  • logger.exception(msg) inside except logs the message AND the full traceback — f"{e}" alone loses the traceback.
  • except Exception: pass hides a real failure with zero trace — log it at minimum, even if you cannot handle it.

Pro tip

  • Set a timeout on every external call by default — without one, a hung dependency can block indefinitely.
  • Put one broad catch at the edge of a system (a boundary), not scattered through internal code.
  • Log full detail internally; return a short, generic message externally — internal detail is an information leak.
  • A retry loop needs a hard cap on attempts and must re-raise the final failure, never discard it silently.

Next up

  • Context Managers

Sources

  • Errors and Exceptions — docs.python.org/3/tutorial/errors.html
  • logging — docs.python.org/3/library/logging.html
  • Every example executed on Python 3.14.3 · checked 2026-08-21

Spotted a mistake on this page? Tell us.