Initial extracted documentation set
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user