Designing Your Exceptions

One base class per package, subclasses for the decisions callers make, and raise ... from to keep the cause attached.

Syntaxraise MyDomainError("what failed", code="...") from original_exception

Raising ValueError("declined") from a payments module forces every caller to match on strings. Define your own hierarchy instead: one base class per package, and a subclass for each decision a caller actually makes.

A small hierarchy

Start with class BillingError(Exception) — the docs say user-defined exceptions should derive from Exception, not BaseException — then add the cases that differ in handling: CardDeclined (do not retry, tell the user), GatewayUnavailable (retry), ConfigurationError (stop the process). Three or four is usually enough; one per error message is a code smell. Carry the machine-readable detail as attributes (exc.code, exc.status) because callers must never parse the message. Translate at your boundary: catch your HTTP library's errors inside the module and re-raise them as yours, so callers do not have to import it.

Catch narrowly, let the rest fly

Catch only what you can act on, as close to the cause as possible. Everything else should propagate: a traceback is information, a swallowed exception is a wrong answer with no evidence. except Exception is the widest you should ever write, and even then log with exc_info and re-raise or return a defined failure. A bare except: catches BaseException, including KeyboardInterrupt and SystemExit.

Keep the cause

raise BillingError("charge failed") from exc sets __cause__, and the traceback says the above exception was the direct cause of the following one. Raising inside an except block without from still records __context__ automatically — but from None sets __suppress_context__ and deletes the original from the traceback. A bare raise re-raises the current exception unchanged, which is the shorthand for raise exc with the traceback intact.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Let callers tell a declined card from an unreachable gateway without reading message text
  • Keep a vendor SDK's exception types from leaking into code that calls your module
  • Preserve the original traceback when you re-raise as your own error type
  • Decide, per except block, whether the right move is retry, report or let it crash

More examples

Translating a dependency's errors at your boundary

The point of translation is that swapping httpx for something else changes one file. The `from exc` clauses keep the HTTP traceback in the log while the public type stays yours - and notice that a 402 and a timeout are different classes because the caller does different things with them.

Example · python
# myapp/billing/errors.py
class BillingError(Exception):
    """Base for every deliberate failure of this package."""


class CardDeclined(BillingError):
    def __init__(self, code: str):
        super().__init__(f"card declined ({code})")
        self.code = code          # "insufficient_funds", "expired_card", ...


class GatewayUnavailable(BillingError):
    """Retryable: the charge may or may not have reached the processor."""


# myapp/billing/gateway.py
import httpx

from myapp.billing.errors import CardDeclined, GatewayUnavailable


def charge(client: httpx.Client, cents: int, token: str) -> str:
    try:
        response = client.post("/charges", json={"cents": cents, "token": token})
        response.raise_for_status()
    except httpx.TimeoutException as exc:
        raise GatewayUnavailable("gateway timed out") from exc
    except httpx.HTTPStatusError as exc:
        if exc.response.status_code == 402:
            raise CardDeclined(exc.response.json()["code"]) from exc
        raise GatewayUnavailable(f"gateway said {exc.response.status_code}") from exc
    return response.json()["charge_id"]


# The caller handles two outcomes and never imports httpx:
#   except CardDeclined as exc:     -> show the user exc.code, do not retry
#   except GatewayUnavailable:      -> retry with backoff

The three ways to swallow, ranked

contextlib.suppress(FileNotFoundError) is the readable form of try/except FileNotFoundError: pass, and its narrowness is the documentation. The middle form is the only defensible broad catch: it exists because one record must not kill a batch, and it still logs the traceback.

Example · python
import contextlib
import logging

logger = logging.getLogger(__name__)

# Best: narrow, intentional, documented by the call itself.
with contextlib.suppress(FileNotFoundError):
    cache_path.unlink()          # already gone is the outcome we wanted

# Acceptable: broad, but the evidence survives and the caller is told.
try:
    enrich(record)
except Exception:                # an enrichment failure must not drop the record
    logger.exception("enrichment failed for record %s", record["id"])

# Never: the bug becomes invisible, and Ctrl-C stops working.
try:
    enrich(record)
except:                          # catches KeyboardInterrupt and SystemExit too
    pass

Adding context without losing the exception

add_note (3.11) attaches a string to an exception already in flight, which is how you say which file or which row without wrapping the exception in a new one. ExceptionGroup with except* (3.11) is for genuinely concurrent or batched work, where collapsing ten failures into the first one loses the information you needed.

Example · python
def load_all(paths):
    failures = []
    for path in paths:
        try:
            load(path)
        except BillingError as exc:
            exc.add_note(f"while loading {path}")   # 3.11+, appears in traceback
            failures.append(exc)
    if failures:
        # 3.11+: report every failure, not just the first one.
        raise ExceptionGroup(f"{len(failures)} file(s) failed", failures)


# A caller can handle one member type and let the rest propagate:
#   try:
#       load_all(paths)
#   except* CardDeclined as group:
#       report(group.exceptions)

Discussion

  • Be the first to comment on this lesson.