Files
python-patterns/patterns/error-handling.md
T
2026-06-01 21:42:05 +00:00

141 lines
4.0 KiB
Markdown

# 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
1. Define a subsystem-level base exception.
2. Add subtypes only when callers need different handling.
3. Put structured context on the exception when branching depends on it.
4. 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 `None` is already the contract
Do not make callers parse exception text. If the distinction matters, make it a type or a field.
## Good shape
```python
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:
```python
try:
send_mail(message)
except TemporaryMailError:
retry_later(message)
except PermanentMailError as exc:
mark_failed(message, reason=str(exc))
```
## Counterexamples
### Stringly-typed branching
```python
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
```python
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
```python
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-71` defines `SMTPException` as the module-wide base type.
- `Lib/smtplib.py:88-100` defines `SMTPResponseException` and stores structured fields on the exception itself: `smtp_code` and `smtp_error`.
- `Lib/smtplib.py:102-125` adds subtype-specific payload like `sender` on `SMTPSenderRefused` and `recipients` on `SMTPRecipientsRefused`.
### HTTPX
- `httpx/_exceptions.py:74-90` defines `HTTPError` as a broad catch point and explicitly documents it as useful around request + `raise_for_status()` flows.
- `httpx/_exceptions.py:107-125` narrows that into `RequestError` and `TransportError` for request-time failures.
- `httpx/_exceptions.py:132-160` further splits timeout handling into `ConnectTimeout`, `ReadTimeout`, `WriteTimeout`, and `PoolTimeout`.
### Click
- `src/click/exceptions.py:35-65` defines `ClickException` with behavior, not just categorization: an exit code and a `show()` renderer.
- `src/click/exceptions.py:68-111` makes `UsageError` a 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.