docs(omo-codex): batch 95 (14 files)
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user