Error Handling¶
Cello provides multiple layers for dealing with errors: returning explicit error responses from handlers, registering global exception handlers, and using RFC 7807 Problem Details for structured error payloads.
Returning Error Responses¶
The simplest approach is to return a Response with an appropriate HTTP status code directly from the handler.
from cello import App, Response
app = App()
@app.get("/users/{id}")
def get_user(request):
user_id = request.params["id"]
user = find_user(user_id)
if user is None:
return Response.json(
{"error": "User not found", "id": user_id},
status=404,
)
return user
Common Status Codes¶
| Code | Meaning | When to Use |
|---|---|---|
400 | Bad Request | Missing or invalid input |
401 | Unauthorized | Missing or invalid authentication |
403 | Forbidden | Authenticated but lacking permissions |
404 | Not Found | Resource does not exist |
409 | Conflict | Duplicate resource (e.g., email already taken) |
422 | Unprocessable Entity | Validation failure on well-formed input |
429 | Too Many Requests | Rate limit exceeded |
500 | Internal Server Error | Unexpected server-side failure |
Exception Handlers¶
Register global exception handlers with the @app.exception_handler decorator. When a handler raises an exception of the registered type, Cello catches it and calls your handler instead of returning a raw 500.
@app.exception_handler(ValueError)
def handle_value_error(request, exc):
return Response.json(
{"error": "Invalid value", "detail": str(exc)},
status=400,
)
@app.exception_handler(PermissionError)
def handle_permission_error(request, exc):
return Response.json(
{"error": "Forbidden", "detail": str(exc)},
status=403,
)
Catch-All Handler¶
Register a handler for Exception as a safety net. This should always return a generic message to avoid leaking internal details.
@app.exception_handler(Exception)
def handle_unexpected(request, exc):
import logging
logging.exception("Unhandled error")
return Response.json(
{"error": "Internal server error"},
status=500,
)
Warning
Exception handlers are matched from most specific to least specific. Register the Exception catch-all last.
RFC 7807 Problem Details¶
RFC 7807 defines a standard JSON format for HTTP error responses. Using it makes your API errors machine-readable and consistent.
Format¶
{
"type": "https://api.example.com/errors/not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "User with ID 42 does not exist",
"instance": "/users/42"
}
Fields¶
| Field | Required | Description |
|---|---|---|
type | Yes | URI identifying the error type |
title | Yes | Short human-readable summary |
status | Yes | HTTP status code |
detail | No | Longer explanation specific to this occurrence |
instance | No | URI of the request that caused the error |
Using ProblemDetails in Cello¶
from cello import App, Response
app = App()
def problem(type_url: str, title: str, status: int,
detail: str = None, instance: str = None, **extra) -> Response:
"""Helper to build an RFC 7807 response."""
body = {"type": type_url, "title": title, "status": status}
if detail:
body["detail"] = detail
if instance:
body["instance"] = instance
body.update(extra)
resp = Response.json(body, status=status)
resp.set_header("Content-Type", "application/problem+json")
return resp
Use the helper in handlers and exception handlers:
@app.get("/users/{id}")
def get_user(request):
user = find_user(request.params["id"])
if not user:
return problem(
type_url="/errors/not-found",
title="User Not Found",
status=404,
detail=f"No user with ID {request.params['id']}",
instance=request.path,
)
return user
@app.exception_handler(ValueError)
def handle_validation(request, exc):
return problem(
type_url="/errors/validation",
title="Validation Error",
status=400,
detail=str(exc),
instance=request.path,
)
Validation Errors¶
When using DTO validation with Pydantic models, Cello automatically returns a 422 response with a list of field-level errors.
from pydantic import BaseModel
class CreateUser(BaseModel):
name: str
email: str
age: int
@app.post("/users")
def create_user(request, body: CreateUser):
return {"name": body.name, "email": body.email}
If the request body is invalid, Cello responds with:
{
"detail": [
{
"loc": ["age"],
"msg": "value is not a valid integer",
"type": "type_error.integer"
}
]
}
Custom Error Types¶
Define application-specific exceptions and register handlers for them.
class NotFoundError(Exception):
def __init__(self, resource: str, resource_id: str):
self.resource = resource
self.resource_id = resource_id
super().__init__(f"{resource} {resource_id} not found")
class ConflictError(Exception):
def __init__(self, message: str):
super().__init__(message)
@app.exception_handler(NotFoundError)
def handle_not_found(request, exc):
return problem(
type_url="/errors/not-found",
title=f"{exc.resource} Not Found",
status=404,
detail=str(exc),
instance=request.path,
)
@app.exception_handler(ConflictError)
def handle_conflict(request, exc):
return problem(
type_url="/errors/conflict",
title="Conflict",
status=409,
detail=str(exc),
)
Then raise them in handlers:
@app.get("/users/{id}")
def get_user(request):
user = find_user(request.params["id"])
if not user:
raise NotFoundError("User", request.params["id"])
return user
Guard Errors¶
When a Guard denies access, it raises GuardError (or a subclass). Cello converts these into 401 or 403 responses automatically. You can customize the format:
from cello.guards import GuardError
@app.exception_handler(GuardError)
def handle_guard_error(request, exc):
return problem(
type_url="/errors/access-denied",
title="Access Denied",
status=exc.status_code,
detail=exc.message,
)
Summary¶
| Approach | Best For |
|---|---|
Response.json(..., status=4xx) | Simple, inline error returns |
@app.exception_handler | Centralizing error formatting |
| RFC 7807 Problem Details | Machine-readable, standardized errors |
| Custom exceptions | Domain-specific error semantics |
| DTO validation | Automatic input validation with Pydantic |