Designing Your Exceptions
One base class per package, subclasses for the decisions callers make, and raise ... from to keep the cause attached.
raise MyDomainError("what failed", code="...") from original_exceptionRaising 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
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.
# 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 backoffThe 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.
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
passAdding 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.
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