6.7 KiB
6.7 KiB
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:
ApiProblemErrorfor domain errors - Layered approach: Routers handle HTTP, Services raise domain errors
Error Types
ApiProblemError
Custom exception for business logic errors:
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:
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:
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:
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:
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:
{
"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:
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
# 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:
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
# WRONG: Use logging
except Exception as e:
print(f"Error: {e}") # Never use print in runtime code
Right way: Use core.logging:
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
# WRONG: No machine-readable code
raise HTTPException(status_code=400, detail="Something went wrong")
Right way: Use code field:
raise ApiProblemError(
status_code=400,
detail=problem_payload(code="OPERATION_FAILED", detail="Something went wrong"),
)
❌ Using HTTPException in service/repository layer
# 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:
class AgentService:
async def enqueue_run(self, ...):
raise ApiProblemError(
status_code=403,
detail=problem_payload(code="AGENT_FORBIDDEN", detail="Forbidden"),
)
❌ Not logging exceptions
# 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:
try:
await service.do_something()
except Exception as exc:
logger.error("operation_failed", error=str(exc), exc_info=True)
raise ApiProblemError(...) from exc