293 lines
6.7 KiB
Markdown
293 lines
6.7 KiB
Markdown
# Error Handling
|
|
|
|
> How errors are handled in this project.
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
This project follows **RFC 7807 Problem Details** for HTTP API errors:
|
|
|
|
- **Standard format**: `application/problem+json`
|
|
- **Error codes**: Machine-readable `UPPER_SNAKE_CASE`
|
|
- **Source of truth**: `docs/protocols/common/http-error-codes.md`
|
|
- **Exception class**: `ApiProblemError` for domain errors
|
|
- **Layered approach**: Routers handle HTTP, Services raise domain errors
|
|
|
|
---
|
|
|
|
## Error Types
|
|
|
|
### `ApiProblemError`
|
|
|
|
**Custom exception for business logic errors:**
|
|
|
|
```python
|
|
from core.http.errors import ApiProblemError, problem_payload
|
|
|
|
# Raise in service/repository/dependencies
|
|
raise ApiProblemError(
|
|
status_code=403,
|
|
detail=problem_payload(code="AGENT_FORBIDDEN", detail="Forbidden"),
|
|
)
|
|
|
|
# With params
|
|
raise ApiProblemError(
|
|
status_code=422,
|
|
detail=problem_payload(
|
|
code="INVALID_RUNTIME_MODE",
|
|
detail="Invalid runtime mode",
|
|
params={"mode": mode, "valid_modes": ["standard", "divination"]}
|
|
),
|
|
)
|
|
```
|
|
|
|
**Properties:**
|
|
- `status_code`: HTTP status (int)
|
|
- `code`: Machine-readable error code (str, `UPPER_SNAKE_CASE`)
|
|
- `detail`: Human-readable message (str)
|
|
- `params`: Optional additional context (dict)
|
|
|
|
### `HTTPException` (FastAPI)
|
|
|
|
**Use ONLY in router layer for HTTP transport errors:**
|
|
|
|
```python
|
|
from fastapi import HTTPException, status
|
|
|
|
# Only in routers, for simple HTTP errors
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Resource not found"
|
|
)
|
|
```
|
|
|
|
**For business logic → use `ApiProblemError` instead.**
|
|
|
|
---
|
|
|
|
## Error Handling Patterns
|
|
|
|
### Service Layer (Business Logic)
|
|
|
|
**Services should raise `ApiProblemError`:**
|
|
|
|
```python
|
|
class AgentService:
|
|
async def enqueue_run(self, *, run_input: RunAgentInput, current_user: CurrentUser) -> TaskAccepted:
|
|
# Validate business rules
|
|
if owner_id != str(current_user.id):
|
|
raise ApiProblemError(
|
|
status_code=403,
|
|
detail=problem_payload(
|
|
code="AGENT_FORBIDDEN",
|
|
detail="Forbidden"
|
|
),
|
|
)
|
|
|
|
# Return success result
|
|
return TaskAccepted(run_id=run_id, thread_id=thread_id)
|
|
```
|
|
|
|
### Repository Layer (Data Access)
|
|
|
|
**Re-raise exceptions as `ApiProblemError` when needed:**
|
|
|
|
```python
|
|
from sqlalchemy.exc import IntegrityError
|
|
from core.http.errors import ApiProblemError, problem_payload
|
|
|
|
class AgentRepository(BaseRepository[AgentChatSession]):
|
|
async def create_session(self, ...) -> AgentChatSession:
|
|
try:
|
|
self._session.add(session)
|
|
await self._session.flush()
|
|
return session
|
|
except IntegrityError as exc:
|
|
raise ApiProblemError(
|
|
status_code=409,
|
|
detail=problem_payload(
|
|
code="DUPLICATE_SESSION",
|
|
detail="Session already exists"
|
|
),
|
|
) from exc
|
|
```
|
|
|
|
### Router Layer (HTTP Endpoints)
|
|
|
|
**Routers handle `ApiProblemError` and convert to HTTP response:**
|
|
|
|
```python
|
|
from fastapi import APIRouter, HTTPException
|
|
from core.http.errors import ApiProblemError
|
|
|
|
router = APIRouter()
|
|
|
|
@router.post("/agent/run")
|
|
async def create_run(
|
|
response: Response,
|
|
current_user: CurrentUser = Depends(get_current_user),
|
|
service: AgentService = Depends(get_agent_service),
|
|
):
|
|
try:
|
|
result = await service.enqueue_run(run_input=input, current_user=current_user)
|
|
return result
|
|
except ApiProblemError as exc:
|
|
response.status_code = exc.status_code
|
|
return {
|
|
"code": exc.code,
|
|
"detail": exc.detail,
|
|
**({"params": exc.params} if exc.params else {}),
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## API Error Responses
|
|
|
|
### RFC 7807 Format
|
|
|
|
**All API errors return `application/problem+json`:**
|
|
|
|
```json
|
|
{
|
|
"code": "AGENT_FORBIDDEN",
|
|
"detail": "Forbidden",
|
|
"params": {
|
|
"resource": "agent_session",
|
|
"action": "update"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Error code registry:**
|
|
- **Single source of truth**: `docs/protocols/common/http-error-codes.md`
|
|
- **Update requirement**: Any create/modify/deprecate of codes requires doc update
|
|
|
|
### Error Response Construction
|
|
|
|
**Use `problem_payload()` helper:**
|
|
|
|
```python
|
|
from core.http.errors import problem_payload
|
|
|
|
# Simple error
|
|
detail = problem_payload(
|
|
code="INVALID_INPUT",
|
|
detail="Invalid input data"
|
|
)
|
|
|
|
# With params
|
|
detail = problem_payload(
|
|
code="VALIDATION_ERROR",
|
|
detail="Validation failed",
|
|
params={"field": "email", "reason": "invalid format"}
|
|
)
|
|
|
|
# Pass to ApiProblemError
|
|
raise ApiProblemError(status_code=422, detail=detail)
|
|
```
|
|
|
|
---
|
|
|
|
## Common Mistakes
|
|
|
|
### ❌ Catching and ignoring exceptions
|
|
|
|
```python
|
|
# WRONG: Silent failure destroys debuggability
|
|
try:
|
|
await service.do_something()
|
|
except Exception:
|
|
pass # Never do this
|
|
```
|
|
|
|
**Right way:** Re-raise or convert to typed error:
|
|
|
|
```python
|
|
try:
|
|
await service.do_something()
|
|
except SomeSpecificError as exc:
|
|
raise ApiProblemError(
|
|
status_code=400,
|
|
detail=problem_payload(code="OPERATION_FAILED", detail=str(exc)),
|
|
) from exc
|
|
```
|
|
|
|
### ❌ Using `print()` for errors
|
|
|
|
```python
|
|
# WRONG: Use logging
|
|
except Exception as e:
|
|
print(f"Error: {e}") # Never use print in runtime code
|
|
```
|
|
|
|
**Right way:** Use `core.logging`:
|
|
|
|
```python
|
|
from core.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
try:
|
|
await service.do_something()
|
|
except Exception as e:
|
|
logger.error("operation_failed", error=str(e), exc_info=True)
|
|
raise
|
|
```
|
|
|
|
### ❌ Using free-text `detail` as only error contract
|
|
|
|
```python
|
|
# WRONG: No machine-readable code
|
|
raise HTTPException(status_code=400, detail="Something went wrong")
|
|
```
|
|
|
|
**Right way:** Use `code` field:
|
|
|
|
```python
|
|
raise ApiProblemError(
|
|
status_code=400,
|
|
detail=problem_payload(code="OPERATION_FAILED", detail="Something went wrong"),
|
|
)
|
|
```
|
|
|
|
### ❌ Using `HTTPException` in service/repository layer
|
|
|
|
```python
|
|
# WRONG: HTTP-specific error in business logic
|
|
class AgentService:
|
|
async def enqueue_run(self, ...):
|
|
raise HTTPException(status_code=403, detail="Forbidden") # Coupled to HTTP
|
|
```
|
|
|
|
**Right way:** Use domain error:
|
|
|
|
```python
|
|
class AgentService:
|
|
async def enqueue_run(self, ...):
|
|
raise ApiProblemError(
|
|
status_code=403,
|
|
detail=problem_payload(code="AGENT_FORBIDDEN", detail="Forbidden"),
|
|
)
|
|
```
|
|
|
|
### ❌ Not logging exceptions
|
|
|
|
```python
|
|
# WRONG: Exception is caught but not logged
|
|
try:
|
|
await service.do_something()
|
|
except Exception:
|
|
raise ApiProblemError(...) # Where did it come from?
|
|
```
|
|
|
|
**Right way:** Log before re-raising:
|
|
|
|
```python
|
|
try:
|
|
await service.do_something()
|
|
except Exception as exc:
|
|
logger.error("operation_failed", error=str(exc), exc_info=True)
|
|
raise ApiProblemError(...) from exc
|
|
``` |