219 lines
6.0 KiB
Markdown
219 lines
6.0 KiB
Markdown
|
|
# Error Handling
|
||
|
|
|
||
|
|
Typed errors, exhaustive matching, union returns, and resource safety.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Typed errors — no bare strings
|
||
|
|
|
||
|
|
Error types carry structured data. Pattern matching works. Callers know exactly what can go wrong.
|
||
|
|
|
||
|
|
```python
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from typing import NewType
|
||
|
|
|
||
|
|
UserId = NewType("UserId", int)
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class UserNotFoundError(Exception):
|
||
|
|
user_id: UserId
|
||
|
|
|
||
|
|
def __str__(self) -> str: # REQUIRED — see note below
|
||
|
|
return f"user {self.user_id} not found"
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class PermissionDeniedError(Exception):
|
||
|
|
user_id: UserId
|
||
|
|
required_role: str
|
||
|
|
|
||
|
|
def __str__(self) -> str:
|
||
|
|
return f"user {self.user_id} needs role {self.required_role}"
|
||
|
|
```
|
||
|
|
|
||
|
|
**`__str__` is mandatory** on dataclass exceptions. `@dataclass` replaces `Exception.__init__`, so `self.args` is always `()`. Without `__str__`, `str(e)` returns an empty string and logging/monitoring breaks.
|
||
|
|
|
||
|
|
```python
|
||
|
|
# BAD
|
||
|
|
raise ValueError("user not found")
|
||
|
|
raise ValueError("permission denied")
|
||
|
|
|
||
|
|
# GOOD
|
||
|
|
raise UserNotFoundError(user_id=uid)
|
||
|
|
raise PermissionDeniedError(user_id=uid, required_role="admin")
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Union returns — expected failures without exceptions
|
||
|
|
|
||
|
|
For failures that are **expected** (not found, validation error, permission denied), return a union instead of raising. Exceptions are for **unexpected** failures (network down, OOM, corrupted data).
|
||
|
|
|
||
|
|
### Define the outcome types
|
||
|
|
|
||
|
|
```python
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class User:
|
||
|
|
id: UserId
|
||
|
|
name: str
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class UserNotFound:
|
||
|
|
id: UserId
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class PermissionDenied:
|
||
|
|
id: UserId
|
||
|
|
reason: str
|
||
|
|
|
||
|
|
type GetUserResult = User | UserNotFound | PermissionDenied
|
||
|
|
```
|
||
|
|
|
||
|
|
### Handle exhaustively
|
||
|
|
|
||
|
|
```python
|
||
|
|
from typing import assert_never
|
||
|
|
|
||
|
|
def handle_result(result: GetUserResult) -> str:
|
||
|
|
match result:
|
||
|
|
case User(name=name):
|
||
|
|
return f"Found: {name}"
|
||
|
|
case UserNotFound(id=uid):
|
||
|
|
return f"No user with id {uid}"
|
||
|
|
case PermissionDenied(reason=reason):
|
||
|
|
return f"Denied: {reason}"
|
||
|
|
case _ as unreachable:
|
||
|
|
assert_never(unreachable)
|
||
|
|
```
|
||
|
|
|
||
|
|
`assert_never` in the default case: if you add a new variant to `GetUserResult` without handling it here, the type checker errors. No silent fall-through.
|
||
|
|
|
||
|
|
### When to use which
|
||
|
|
|
||
|
|
**The heuristic**: caller is 1-2 levels away and MUST handle it → union return. Error should propagate up many layers to a boundary → exception.
|
||
|
|
|
||
|
|
| Scenario | Pattern | Why |
|
||
|
|
|---|---|---|
|
||
|
|
| Repository → service (caller handles it) | Union return (`User \| UserNotFound`) | Caller is right there, must handle both |
|
||
|
|
| Validation at boundary (parsing input) | Exception (typed, with fields) | Propagates up to HTTP/CLI handler |
|
||
|
|
| Infrastructure failure (network, OOM) | Exception | Can't handle locally, must propagate |
|
||
|
|
| Service → service (deep internal) | Exception (typed) | Union boilerplate across many layers is worse than exceptions |
|
||
|
|
| HTTP handler → response | Catch exceptions, convert to response | Boundary code catches and translates |
|
||
|
|
|
||
|
|
**Practical tradeoff**: union returns are safest (type checker forces handling) but create boilerplate when every caller in a chain must `match`. If the error would just propagate through 3+ layers unchanged, use a typed exception instead.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Exhaustive match — every match needs a default
|
||
|
|
|
||
|
|
Every `match` statement ends with `case _: assert_never(x)`. No exceptions.
|
||
|
|
|
||
|
|
```python
|
||
|
|
from enum import StrEnum
|
||
|
|
from typing import assert_never
|
||
|
|
|
||
|
|
class Status(StrEnum):
|
||
|
|
PENDING = "pending"
|
||
|
|
ACTIVE = "active"
|
||
|
|
DELETED = "deleted"
|
||
|
|
|
||
|
|
def describe(status: Status) -> str:
|
||
|
|
match status:
|
||
|
|
case Status.PENDING:
|
||
|
|
return "waiting"
|
||
|
|
case Status.ACTIVE:
|
||
|
|
return "live"
|
||
|
|
case Status.DELETED:
|
||
|
|
return "gone"
|
||
|
|
case _ as unreachable:
|
||
|
|
assert_never(unreachable)
|
||
|
|
```
|
||
|
|
|
||
|
|
Add a new enum member? The type checker tells you every `match` that needs updating.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Context managers — resource safety
|
||
|
|
|
||
|
|
If it has `.close()`, `.shutdown()`, `.disconnect()`, or `.release()`, wrap it in `with`.
|
||
|
|
|
||
|
|
```python
|
||
|
|
# BAD
|
||
|
|
f = open("data.txt")
|
||
|
|
data = f.read()
|
||
|
|
f.close() # forgotten? leaked
|
||
|
|
|
||
|
|
# GOOD
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
data = Path("data.txt").read_text()
|
||
|
|
```
|
||
|
|
|
||
|
|
### Async resources
|
||
|
|
|
||
|
|
```python
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
async def fetch_users() -> list[User]:
|
||
|
|
async with httpx.AsyncClient() as client:
|
||
|
|
response = await client.get("https://api.example.com/users")
|
||
|
|
response.raise_for_status()
|
||
|
|
return [User(**u) for u in response.json()]
|
||
|
|
```
|
||
|
|
|
||
|
|
### Custom context manager
|
||
|
|
|
||
|
|
```python
|
||
|
|
from contextlib import asynccontextmanager
|
||
|
|
from collections.abc import AsyncIterator
|
||
|
|
|
||
|
|
@asynccontextmanager
|
||
|
|
async def managed_connection(url: str) -> AsyncIterator[Connection]:
|
||
|
|
conn = await connect(url)
|
||
|
|
try:
|
||
|
|
yield conn
|
||
|
|
finally:
|
||
|
|
await conn.close()
|
||
|
|
|
||
|
|
async with managed_connection("postgres://...") as conn:
|
||
|
|
await conn.execute("SELECT 1")
|
||
|
|
# conn is closed here, guaranteed
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Exception hierarchy — when you do raise
|
||
|
|
|
||
|
|
Keep exception hierarchies shallow and specific.
|
||
|
|
|
||
|
|
```python
|
||
|
|
class AppError(Exception):
|
||
|
|
"""Base for all application errors."""
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class NotFoundError(AppError):
|
||
|
|
entity: str
|
||
|
|
id: int
|
||
|
|
|
||
|
|
def __str__(self) -> str:
|
||
|
|
return f"{self.entity} {self.id} not found"
|
||
|
|
|
||
|
|
@dataclass(frozen=True, slots=True)
|
||
|
|
class ConflictError(AppError):
|
||
|
|
entity: str
|
||
|
|
field: str
|
||
|
|
value: str
|
||
|
|
|
||
|
|
def __str__(self) -> str:
|
||
|
|
return f"{self.entity}.{self.field} = {self.value!r} already exists"
|
||
|
|
```
|
||
|
|
|
||
|
|
Callers catch `AppError` at the boundary, or specific subtypes where they can do something useful.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Sources
|
||
|
|
|
||
|
|
- Python docs: [typing — assert_never](https://docs.python.org/3/library/typing.html#typing.assert_never)
|
||
|
|
- Python docs: [contextlib](https://docs.python.org/3/library/contextlib.html)
|
||
|
|
- Python docs: [match statement](https://docs.python.org/3/reference/compound_stmts.html#the-match-statement)
|