4.0 KiB
Error Handling
Use exception types to encode what callers can do next.
Why
Good Python libraries do not collapse every failure into RuntimeError or Exception. They shape errors around recovery boundaries:
- one base type for “something in this subsystem failed”
- narrower subtypes when callers need different recovery
- structured fields when the branch depends on data, not wording
That gives callers a clean ladder:
- catch broadly at subsystem boundaries
- catch narrowly when retry/report/ignore differs
- surface better API or CLI errors without parsing strings
The pattern
- Define a subsystem-level base exception.
- Add subtypes only when callers need different handling.
- Put structured context on the exception when branching depends on it.
- Translate internal failures at API, CLI, or transport boundaries.
When to use
Use this when:
- a module or library exposes a public API
- failure modes need different handling
- an outer boundary must turn internal failures into user-facing errors
- retry, ignore, and abort decisions differ by failure kind
When not to use
Do not build a hierarchy when:
- the code is tiny and has one obvious failure mode
- every failure is handled the same way
- the only distinction is wording, not behavior
- a normal return value like
Noneis already the contract
Do not make callers parse exception text. If the distinction matters, make it a type or a field.
Good shape
class MailError(Exception):
pass
class TemporaryMailError(MailError):
pass
class PermanentMailError(MailError):
pass
class MailRejected(PermanentMailError):
def __init__(self, code: int, reason: str) -> None:
super().__init__(reason)
self.code = code
self.reason = reason
Caller:
try:
send_mail(message)
except TemporaryMailError:
retry_later(message)
except PermanentMailError as exc:
mark_failed(message, reason=str(exc))
Counterexamples
Stringly-typed branching
try:
do_work()
except Exception as exc:
if "timeout" in str(exc).lower():
retry()
The recovery rule is hiding in fragile text matching.
One catch-all with no domain meaning
class AppError(Exception):
pass
raise AppError("not found")
raise AppError("permission denied")
raise AppError("timeout")
Callers cannot branch meaningfully.
Boundary types leaking into the core
from fastapi import HTTPException
def charge_card(card: Card) -> Receipt:
if card.expired:
raise HTTPException(status_code=400, detail="expired card")
This couples domain logic to one transport. Raise a domain error here; translate to HTTP at the boundary.
Source signals
Stdlib / CPython
Lib/smtplib.py:69-71definesSMTPExceptionas the module-wide base type.Lib/smtplib.py:88-100definesSMTPResponseExceptionand stores structured fields on the exception itself:smtp_codeandsmtp_error.Lib/smtplib.py:102-125adds subtype-specific payload likesenderonSMTPSenderRefusedandrecipientsonSMTPRecipientsRefused.
HTTPX
httpx/_exceptions.py:74-90definesHTTPErroras a broad catch point and explicitly documents it as useful around request +raise_for_status()flows.httpx/_exceptions.py:107-125narrows that intoRequestErrorandTransportErrorfor request-time failures.httpx/_exceptions.py:132-160further splits timeout handling intoConnectTimeout,ReadTimeout,WriteTimeout, andPoolTimeout.
Click
src/click/exceptions.py:35-65definesClickExceptionwith behavior, not just categorization: an exit code and ashow()renderer.src/click/exceptions.py:68-111makesUsageErrora narrower subtype with a different exit code and help-aware output.
Bottom line
If callers need different behavior, give them different exception types.
If callers need details, attach fields.
If an outer layer needs user-facing output, translate there instead of pushing boundary concerns through the whole codebase.