141 lines
4.0 KiB
Markdown
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.
|