Files
eryao/.trellis/spec/backend/error-handling.md
T

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: ApiProblemError for 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