Skip to content

Exception Handling Pipeline

Exception hierarchy, ExceptionMiddleware, error handlers, status codes

Sillo’s exception handling subsystem converts Python exceptions raised during request processing into well-formed HTTP responses (or WebSocket close frames). The design achieves three things:

  1. Uniform error contract: Every error response from the framework follows a predictable JSON shape so API clients never need to guess.
  2. Two-tier dispatch: Status-code-based handlers (fast, integer-key lookup) are tried first for HTTPException instances; class-based handlers (MRO walk) handle everything else.
  3. Polymorphic fallback. The MRO walk means registering a handler for a base class automatically covers all subclasses unless a more specific handler is registered.
PrincipleHow it manifests
Fail closedUnhandled exceptions are logged with full traceback and re-raised; the server error middleware catches them.
Content negotiationhandle_404_error inspects the Accept header: browsers get HTML, API clients get JSON, fallback is plain text.
No information leaks in productionDebug mode is opt-in; generic messages are the default. The ResponseValidationError handler deliberately omits the offending value.
Explicit over implicitThe two registries are separate dictionaries. Developers choose whether to match by status code or exception class.
Single source of truthValidation errors always carry location-prefixed loc arrays so clients know which request part failed.

Sillo defines three top-level exception classes, each in its own module. The full inheritance tree looks like this:

graph TD
    EX["Exception"]
    EX --> HTTP["HTTPException<br/><i>sillo/exceptions.py</i>"]
    EX --> RVE["RequestValidationError<br/><i>sillo/validation/errors.py</i>"]
    EX --> RSE["ResponseValidationError<br/><i>sillo/validation/errors.py</i>"]
    EX --> WSE["WebSocketException<br/><i>sillo/exceptions.py</i>"]

    HTTP --> NF["NotFoundException<br/><i>sillo/exceptions.py</i>"]
    HTTP --> AUTH["AuthException<br/><i>sillo/auth/exceptions.py</i>"]
    AUTH --> AF["AuthenticationFailed"]
    AUTH --> PD["PermissionDenied"]
classDiagram
    class Exception {
        <<Python builtin>>
    }

    class HTTPException {
        +int status_code
        +Any detail
        +dict headers
        +__str__() str
        +__repr__() str
    }

    class NotFoundException {
        +status_code = 404
        +__init__(detail?, headers?)
    }

    class AuthException {
        +int status_code
        +str detail
        +dict headers
    }

    class AuthenticationFailed {
        +status_code = 401
        +detail = "Authentication failed"
    }

    class PermissionDenied {
        +status_code = 403
        +detail = "Permission denied"
    }

    class RequestValidationError {
        +list errors
        +Any body
    }

    class ResponseValidationError {
        +list errors
        +Any body
    }

    class WebSocketException {
        +int code
        +str reason
        +__str__() str
        +__repr__() str
    }

    Exception <|-- HTTPException
    Exception <|-- RequestValidationError
    Exception <|-- ResponseValidationError
    Exception <|-- WebSocketException
    HTTPException <|-- NotFoundException
    HTTPException <|-- AuthException
    AuthException <|-- AuthenticationFailed
    AuthException <|-- PermissionDenied

File: core/sillo/exceptions.py (lines 15 to 115)

The root of all HTTP error exceptions. Every handler that wants to produce a non-2xx response raises this (or a subclass).

class HTTPException(Exception):
def __init__(
self,
status_code: int,
detail: typing.Any | None = None,
headers: dict[str, typing.Any] = {},
) -> None:
super().__init__(detail or http.HTTPStatus(status_code).phrase)
self.status_code = status_code
self.detail = self.args[0]
self.headers = headers

Key behaviors:

  • If no detail is provided, the standard HTTP reason phrase from http.HTTPStatus is used (e.g., 404 → "Not Found").
  • The detail is stored both in self.args[0] (via super().__init__) and in self.detail. This makes it work with str(exc) and exc.detail alike.
  • The headers dict defaults to {} (mutable default: intentional, only read, never mutated by the framework).
  • __str__ returns "HTTP {status_code}: {detail}".
  • __repr__ returns "HTTPException(404, 'Not Found')" (uses the actual class name, so subclasses get the right name).
# Explicit status + detail
raise HTTPException(status_code=400, detail="Bad request body")
# Status-only (detail auto-derived from HTTPStatus)
raise HTTPException(status_code=429)
# With custom headers (e.g., WWW-Authenticate on 401)
raise HTTPException(
status_code=401,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)

File: core/sillo/exceptions.py (lines 118 to 159)

A convenience subclass that hardcodes status_code=404:

class NotFoundException(HTTPException):
def __init__(
self,
detail: str | None = None,
headers: dict[str, typing.Any] = {},
) -> None:
super().__init__(
status_code=404,
detail=detail or "Not Found",
headers=headers,
)

Why a separate class? Two reasons:

  1. The ExceptionMiddleware registers a class-based handler for NotFoundExceptionhandle_404_error, which provides content negotiation (HTML for browsers, JSON for APIs). If you raise a bare HTTPException(404), you get the generic JSON handler instead.
  2. It makes intent explicit in code: raise NotFoundException() reads better than raise HTTPException(404).

File: core/sillo/exceptions.py (lines 162 to 242)

A completely separate exception hierarchy (does NOT inherit from HTTPException) because WebSocket connections use close codes, not HTTP status codes:

class WebSocketException(Exception):
def __init__(self, code: int, reason: str | None = None) -> None:
super().__init__(reason or "")
self.code = code
self.reason = self.args[0]
  • code is a WebSocket close code (RFC 6455): 1000 (normal), 1008 (policy violation), 1011 (internal error), etc.
  • reason is an optional human-readable string (limited to 123 bytes by the WebSocket protocol).
  • __str__"WebSocket {code}: {reason}".
  • __repr__"WebSocketException(1008, 'Policy violation')".

WebSocket exceptions are caught by a completely separate middleware (WebSocketErrorMiddleware in core/sillo/websockets/errors.py), not by ExceptionMiddleware.

2.4 AuthException / AuthenticationFailed / PermissionDenied

Section titled “2.4 AuthException / AuthenticationFailed / PermissionDenied”

File: core/sillo/auth/exceptions.py (lines 16 to 161)

class AuthException(HTTPException):
def __init__(
self, status_code: int, detail: str, headers: HeadersType | None = None
) -> None:
super().__init__(status_code, detail, headers or {})
class AuthenticationFailed(AuthException):
def __init__(
self,
detail: str = "Authentication failed",
headers: HeadersType | None = None,
) -> None:
super().__init__(401, detail, headers)
class PermissionDenied(AuthException):
def __init__(
self,
detail: str = "Permission denied",
headers: HeadersType | None = None,
) -> None:
super().__init__(403, detail, headers)

The inheritance chain is: AuthenticationFailedAuthExceptionHTTPExceptionException.

This means:

  • Catching HTTPException catches auth errors too (they produce HTTP responses).
  • Catching AuthException catches both AuthenticationFailed and PermissionDenied.
  • The ExceptionMiddleware registers AuthenticationFailedAuthErrorHandler at the class level, so auth failures get a dedicated JSON response handler.

2.5 RequestValidationError / ResponseValidationError

Section titled “2.5 RequestValidationError / ResponseValidationError”

File: core/sillo/validation/errors.py (lines 68 to 126)

These inherit directly from Exception (NOT from HTTPException). They carry structured error lists rather than a single status code:

class RequestValidationError(Exception):
def __init__(self, errors: list[dict[str, Any]], *, body: Any = None) -> None:
self.errors = errors
self.body = body
super().__init__(f"{len(errors)} validation error(s) in request")
class ResponseValidationError(Exception):
def __init__(self, errors: list[dict[str, Any]], *, body: Any = None) -> None:
self.errors = errors
self.body = body
super().__init__(f"{len(errors)} validation error(s) in response")

Why not inherit from HTTPException? Because ResponseValidationError maps to HTTP 500 (server error, not client error), while RequestValidationError maps to 422. If they inherited from HTTPException, the status-code-based dispatch would kick in and could conflict with the class-based handlers. By being plain Exception subclasses, they always go through the class-based MRO lookup.


File: core/sillo/exception_handler.py (lines 129 to 261)

ExceptionMiddleware is a standard sillo middleware that wraps request processing in a try/except. It is not an ASGI middleware directly. It conforms to the sillo middleware signature:

async def __call__(
self,
request: Request,
response: Response,
call_next: Callable[[], Awaitable[Response]],
) -> Response
class ExceptionMiddleware:
def __init__(self) -> None:
self.debug = False
self._status_handlers: dict[int, ExceptionHandlerType] = {}
self._exception_handlers = {
HTTPException: self.http_exception,
AuthenticationFailed: AuthErrorHandler,
NotFoundException: handle_404_error,
ValidationError: pydantic_validation_error_handler,
RequestValidationError: request_validation_error_handler,
ResponseValidationError: response_validation_error_handler,
}

On construction, the middleware creates:

  • An empty _status_handlers dict (integer keys → handler callables).
  • A pre-populated _exception_handlers dict (class keys → handler callables) with six default entries.
async def __call__(self, request, response, call_next):
if len(self._exception_handlers) == 0 and len(self._status_handlers) == 0:
return await call_next() # fast path: no handlers at all
return await wrap_http_exceptions(
request=request,
response=response,
call_next=call_next,
exception_handlers=self._exception_handlers,
status_handlers=self._status_handlers,
)

The fast-path optimization skips the try/except wrapper when both registries are empty. In practice this never fires because the constructor pre-populates _exception_handlers.


The ExceptionMiddleware maintains two separate dictionaries:

4.1 _status_handlers: dict[int, ExceptionHandlerType]

Section titled “4.1 _status_handlers: dict[int, ExceptionHandlerType]”
  • Keys: Integer HTTP status codes (e.g., 404, 500, 429).
  • Values: Async handler callables with signature (request, response, exc) -> Response.
  • Lookup: Only checked when the exception is an HTTPException instance. The lookup uses exc.status_code as the key.
  • Priority: Checked first, before class-based handlers. If a status-code handler is found, the class-based handler is never consulted.
  • Default: Empty on init. Applications populate it via add_exception_handler(int, handler).
# Register a custom handler for 429 Too Many Requests
app.add_exception_handler(429, rate_limit_handler)

4.2 _exception_handlers: dict[type[Exception], ExceptionHandlerType]

Section titled “4.2 _exception_handlers: dict[type[Exception], ExceptionHandlerType]”
  • Keys: Exception classes (not instances).
  • Values: Async handler callables.
  • Lookup: Uses MRO traversal (_lookup_exception_handler).
  • Priority: Checked second, only if no status-code handler matched (or the exception is not an HTTPException).
  • Default: Pre-populated with six entries (see §3 above).
# Register a custom handler for a domain exception
app.add_exception_handler(InsufficientCreditsError, credits_error_handler)
flowchart TD
    A["Exception raised"] --> B{"Is it an HTTPException?"}
    B -- Yes --> C{"Status code in\n_status_handlers?"}
    C -- Yes --> D["Execute status handler\n(PRIORITY 1)"]
    C -- No --> E{"Class in\n_exception_handlers\nor MRO match?"}
    B -- No --> E
    E -- Found --> F["Execute class handler\n(PRIORITY 2)"]
    E -- Not found --> G["Log traceback\nRe-raise exception"]
    D --> H["Return Response"]
    F --> H

This two-tier design means:

  • You can override the handler for all 404 responses by registering a status-code handler: app.add_exception_handler(404, my_handler). This takes precedence over the class-based NotFoundException handler.
  • You can register a handler for a specific exception class (e.g., RateLimitExceeded) without touching status codes.
  • The status-code registry is O(1) lookup; the class registry is O(n) where n is the MRO depth (typically ≤ 5).

File: core/sillo/exception_handler.py (lines 28 to 60)

def _lookup_exception_handler(
exc_handlers: dict[int | type[Exception], ExceptionHandlerType],
exc: Exception,
):
for cls in type(exc).__mro__:
if cls in exc_handlers:
return exc_handlers[cls]
return None

The algorithm walks the exception’s Method Resolution Order (MRO), which for a typical Python class looks like:

>>> type(exc).__mro__
(AuthenticationFailed, AuthException, HTTPException, Exception, object)

At each step, it checks if that class is a key in exc_handlers. The first match wins, which gives the most specific handler.

Suppose the registry contains:

{
HTTPException: http_exception_handler,
AuthenticationFailed: auth_handler,
Exception: fallback_handler,
}

And an AuthenticationFailed is raised. The MRO walk is:

StepClass in MROIn registry?Action
1AuthenticationFailed✅ YesReturn auth_handlermatch
2AuthException(not reached)
3HTTPException(not reached)
4Exception(not reached)
5object(not reached)

If the exception were a bare HTTPException(403) (not an AuthException subclass):

StepClass in MROIn registry?Action
1HTTPException✅ YesReturn http_exception_handlermatch

If the exception were a ValueError (nothing in the registry matches):

StepClass in MROIn registry?Action
1ValueError❌ NoContinue
2Exception✅ YesReturn fallback_handlermatch
3object(not reached)

If even Exception is not in the registry, the walk reaches object, finds nothing, and returns None. The caller logs the traceback and re-raises.

flowchart TD
    START["exc = AuthenticationFailed()"] --> MRO["type(exc).__mro__\n= [AuthenticationFailed, AuthException,\n   HTTPException, Exception, object]"]
    MRO --> S1{"AuthenticationFailed\nin registry?"}
    S1 -- Yes --> R1["✅ Return handler"]
    S1 -- No --> S2{"AuthException\nin registry?"}
    S2 -- Yes --> R2["✅ Return handler"]
    S2 -- No --> S3{"HTTPException\nin registry?"}
    S3 -- Yes --> R3["✅ Return handler"]
    S3 -- No --> S4{"Exception\nin registry?"}
    S4 -- Yes --> R4["✅ Return handler"]
    S4 -- No --> S5{"object\nin registry?"}
    S5 -- Yes --> R5["✅ Return handler"]
    S5 -- No --> NONE["❌ Return None\n→ log + re-raise"]

6. The wrap_http_exceptions Dispatch Function

Section titled “6. The wrap_http_exceptions Dispatch Function”

File: core/sillo/exception_handler.py (lines 63 to 126)

This is the heart of the exception pipeline. It is called by ExceptionMiddleware.__call__ and contains the full try/except dispatch logic:

async def wrap_http_exceptions(
request: Request,
response: Response,
call_next: Callable[..., Awaitable[Response]],
exception_handlers: dict[int | type[Exception], ExceptionHandlerType],
status_handlers: dict[int, ExceptionHandlerType],
):
exception_handlers = exception_handlers or {}
status_handlers = status_handlers or {}
try:
return await call_next()
except Exception as exc:
handler: ExceptionHandlerType | None = None
# Step 1: Status-code lookup (HTTPException only)
if isinstance(exc, HTTPException):
handler = status_handlers.get(exc.status_code)
if handler:
return await handler(request, response, exc)
# Step 2: Class-based MRO lookup
if handler is None:
handler = _lookup_exception_handler(exception_handlers, exc)
if not handler:
error = traceback.format_exc()
logger.error(error)
raise
return await handler(request, response, exc)
  1. Execute call_next(): runs the next middleware or route handler.
  2. If an exception is raised: a. If it’s an HTTPException, look up exc.status_code in status_handlers. If found, call that handler immediately. Return. b. If no status handler matched (or the exception is not an HTTPException), perform MRO-based lookup in exception_handlers. c. If a class handler is found, call it and return. d. If nothing matches, log the full traceback and re-raise. The server error middleware (ServerErrorMiddleware) catches it and produces a 500.

The variable handler is initialized to None and is used as a sentinel. The if handler is None check on line 120 is reached in two cases:

  1. The exception is not an HTTPException (so the isinstance check on line 115 was False, and handler was never assigned).
  2. The exception IS an HTTPException but no status-code handler was found (status_handlers.get(exc.status_code) returned None).

In both cases, the class-based lookup runs. This means:

  • An HTTPException(404) with no status-code handler will match the class-based HTTPException handler (the default http_exception method).
  • A NotFoundException (which IS an HTTPException) with no status-code handler will match NotFoundException first in the MRO walk (more specific), falling back to HTTPException if needed.

7.1 HTTPException → http_exception (JSON or empty)

Section titled “7.1 HTTPException → http_exception (JSON or empty)”

File: core/sillo/exception_handler.py (lines 263 to 296)

async def http_exception(
self, request: Request, response: Response, exc: HTTPException
) -> Response:
assert isinstance(exc, HTTPException)
if exc.status_code in {204, 304}:
return response.empty(status_code=exc.status_code, headers=exc.headers)
return response.json(
exc.detail, status_code=exc.status_code, headers=exc.headers
)

Behavior:

StatusResponse
204, 304Empty body, appropriate status code, includes custom headers
Everything elseresponse.json(exc.detail, status_code=..., headers=...)

The 204/304 special case exists because HTTP specifications prohibit response bodies for these status codes. The handler returns response.empty() which produces a response with no body.

Response shape (non-204/304):

"detail message here"

Note: the response body is exc.detail directly (a string or any JSON-serializable value), not wrapped in a {"detail": ...} envelope. This differs from the 404 handler (§7.3) and the validation handlers (§7.4 to 7.6).

7.2 AuthenticationFailed → AuthErrorHandler

Section titled “7.2 AuthenticationFailed → AuthErrorHandler”

File: core/sillo/auth/exceptions.py (lines 164 to 197)

async def AuthErrorHandler(
request: Request, response: Response, exc: HTTPException
) -> Any:
return response.json(exc.detail, status_code=exc.status_code, headers=exc.headers)

This handler is registered for the AuthenticationFailed class. Because of MRO lookup, it also catches any subclass of AuthenticationFailed (though there are none currently).

Response shape:

"Authentication failed"

Same as the generic http_exception handler. The body is exc.detail directly. The handler exists as a separate entry point so applications can override it independently of the generic HTTPException handler.

7.3 NotFoundException → handle_404_error (Content-Negotiated 404)

Section titled “7.3 NotFoundException → handle_404_error (Content-Negotiated 404)”

File: core/sillo/handlers/not_found.py (lines 62 to 118)

This is the most sophisticated built-in handler. It performs content negotiation based on the client’s Accept header:

async def handle_404_error(
request: Request,
response: Response,
exception: NotFoundException,
) -> Response:
debug = _debug_enabled(request)
if debug:
error_message = exception.detail
traceback_info = traceback.format_exc()
if traceback_info.strip() == "NoneType: None":
traceback_info = None
else:
error_message = GENERIC_MESSAGE # "The page you are looking for does not exist."
traceback_info = None
if _prefers_html(request):
return response.html(
generate_html_page("404 - Not Found", error_message),
status_code=404,
)
if request.accepts_json:
error_details = {
"status": 404,
"error": http.HTTPStatus(404).phrase,
"message": error_message,
}
if traceback_info:
error_details["traceback"] = traceback_info
return response.json(error_details, status_code=404)
return response.text(
f"404 - Not Found\n{error_message}",
status_code=404,
)

Content negotiation logic:

flowchart TD
    REQ["NotFoundException raised"] --> DBG{"debug enabled?"}
    DBG -- Yes --> DET["error_message = exception.detail\ntraceback = formatted"]
    DBG -- No --> GEN["error_message = GENERIC_MESSAGE\ntraceback = None"]
    DET --> HTML{"Accept header\ncontains text/html?"}
    GEN --> HTML
    HTML -- Yes --> HTMLR["response.html(HTML page, 404)"]
    HTML -- No --> JSON{"Accept header\ncontains application/json\nor accepts_json?"}
    JSON -- Yes --> JSONR["response.json({...}, 404)\n+ optional traceback"]
    JSON -- No --> TEXTR["response.text('404 - Not Found\\n...', 404)"]

Debug flag resolution (_debug_enabled, line 121 to 144):

def _debug_enabled(request: Request) -> bool:
scope = getattr(request, "scope", {}) or {}
for key in ("base_app", "app"):
candidate = scope.get(key)
if candidate is not None and hasattr(candidate, "debug"):
return bool(candidate.debug)
return False

The handler looks for base_app first, then app in the ASGI scope. This is because app holds the router (which has no debug flag), while base_app holds the SilloApp instance. If neither has a debug attribute, the default is False (production-safe).

HTML preference check (_prefers_html, line 147 to 162):

def _prefers_html(request: Request) -> bool:
accept = request.headers.get("accept", "")
return "text/html" in accept or "application/xhtml+xml" in accept

A wildcard */* is NOT treated as a preference for HTML, only explicit text/html or application/xhtml+xml triggers the HTML page.

7.4 ValidationError → pydantic_validation_error_handler (422)

Section titled “7.4 ValidationError → pydantic_validation_error_handler (422)”

File: core/sillo/exception_handler.py (lines 359 to 407)

Handles raw Pydantic ValidationError instances (from model construction, not from request validation):

async def pydantic_validation_error_handler(
request: Request, response: Response, exc: ValidationError
) -> Response:
errors = exc.errors()
error_dict = {}
for e in errors:
loc, msg = e["loc"], e["msg"]
if len(loc) == 1:
error_dict[loc[0]] = msg
elif len(loc) == 2:
nested = error_dict.get(loc[0])
if not isinstance(nested, dict):
nested = {}
error_dict[loc[0]] = nested
nested[loc[1]] = msg
else:
error_dict[".".join(map(str, loc))] = msg
return response.json(
{"error": "Validation Error", "errors": error_dict},
status_code=422,
)

Response shape:

{
"error": "Validation Error",
"errors": {
"name": "field required",
"address": {
"city": "field required"
},
"items.0.price": "ensure this value is greater than 0"
}
}

The nesting strategy:

  • 1-level path: flat key ("name": "...")
  • 2-level path: nested dict ("address": {"city": "..."})
  • 3+ level path: dot-joined key ("items.0.price": "...")

7.5 RequestValidationError → request_validation_error_handler (422)

Section titled “7.5 RequestValidationError → request_validation_error_handler (422)”

File: core/sillo/exception_handler.py (lines 299 to 323)

async def request_validation_error_handler(
request: Request, response: Response, exc: RequestValidationError
) -> Response:
return response.json({"detail": exc.errors}, status_code=422)

Response shape:

{
"detail": [
{
"loc": ["query", "page"],
"msg": "value is not a valid integer",
"type": "type_error.integer"
},
{
"loc": ["body", "name"],
"msg": "field required",
"type": "value_error.missing"
}
]
}

The key difference from the Pydantic handler: RequestValidationError already carries location-prefixed errors (the prefix_errors function in core/sillo/validation/errors.py adds the location prefix). The handler serializes them directly.

7.6 ResponseValidationError → response_validation_error_handler (500)

Section titled “7.6 ResponseValidationError → response_validation_error_handler (500)”

File: core/sillo/exception_handler.py (lines 326 to 356)

async def response_validation_error_handler(
request: Request, response: Response, exc: ResponseValidationError
) -> Response:
logger.error(
"Response validation failed for %s %s: %s",
request.method,
request.url.path,
exc.errors,
)
return response.json(
{"error": "Internal Server Error", "detail": "Response validation failed"},
status_code=500,
)

Response shape:

{
"error": "Internal Server Error",
"detail": "Response validation failed"
}

Critical design decision: The response does NOT include exc.errors or exc.body. This is intentional. The handler’s return value violated the response model, which means it may contain data that the response model was supposed to filter out. Echoing it back could leak internal state.

The error details ARE logged server-side for debugging.

Exception ClassHandlerHTTP StatusResponse Body
HTTPExceptionhttp_exceptionexc.status_codeexc.detail (raw JSON)
HTTPException (204/304)http_exception204/304Empty body
AuthenticationFailedAuthErrorHandler401exc.detail (raw JSON)
NotFoundExceptionhandle_404_error404HTML, JSON, or plain text (content-negotiated)
ValidationError (Pydantic)pydantic_validation_error_handler422{"error": "...", "errors": {...}}
RequestValidationErrorrequest_validation_error_handler422{"detail": [...]}
ResponseValidationErrorresponse_validation_error_handler500{"error": "...", "detail": "..."}

File: core/sillo/record/exceptions.py (lines 1 to 110)

Sillo provides built-in handlers for Tortoise ORM exceptions. These are NOT registered by default. Applications must call register_db_exception_handlers(app):

def register_db_exception_handlers(app) -> None:
app.add_exception_handler(DoesNotExist, handle_does_not_exist)
app.add_exception_handler(IntegrityError, handle_integrity_error)
app.add_exception_handler(ValidationError, handle_validation_error)
app.add_exception_handler(OperationalError, handle_operational_error)
async def handle_does_not_exist(request, response, exc: DoesNotExist):
return response.json(
{"error": "Not Found", "detail": str(exc)},
status_code=404,
)

Triggered when a Tortoise .get() query finds no matching record.

async def handle_integrity_error(request, response, exc: IntegrityError):
return response.json(
{"error": "Conflict", "detail": str(exc)},
status_code=409,
)

Triggered on unique constraint violations, foreign key violations, or null constraint violations at the database level.

async def handle_validation_error(request, response, exc: ValidationError):
return response.json(
{"error": "Validation Error", "detail": str(exc)},
status_code=422,
)

Triggered by Tortoise model-level validation (type mismatches, out-of-range values, etc.). Note: this is tortoise.exceptions.ValidationError, NOT pydantic.ValidationError.

async def handle_operational_error(request, response, exc: OperationalError):
return response.json(
{"error": "Service Unavailable", "detail": "Database unavailable"},
status_code=503,
)

Triggered when the database is unreachable (connection refused, timeout, network partition). The detail message is deliberately generic to avoid leaking infrastructure details.

sequenceDiagram
    participant App as SilloApp
    participant MW as ExceptionMiddleware
    participant DB as record/exceptions.py

    App->>DB: register_db_exception_handlers(app)
    DB->>MW: add_exception_handler(DoesNotExist, handle_does_not_exist)
    DB->>MW: add_exception_handler(IntegrityError, handle_integrity_error)
    DB->>MW: add_exception_handler(ValidationError, handle_validation_error)
    DB->>MW: add_exception_handler(OperationalError, handle_operational_error)

    Note over MW: _exception_handlers now has<br/>4 additional class-based entries

Collision Warning: Tortoise ValidationError vs Pydantic ValidationError

Section titled “Collision Warning: Tortoise ValidationError vs Pydantic ValidationError”

The default ExceptionMiddleware registers a handler for pydantic.ValidationError. When register_db_exception_handlers is called, it adds a handler for tortoise.exceptions.ValidationError. These are different classes. There is no collision. The MRO lookup correctly resolves to the right handler based on the exception’s actual type.

However, if you register a handler for the base Exception class, it will catch BOTH validation error types (and everything else). Be specific.


File: core/sillo/websockets/errors.py (lines 1 to 40)

WebSocket connections have a completely separate exception pipeline. ExceptionMiddleware does NOT handle WebSocket exceptions. They are caught by WebSocketErrorMiddleware:

class WebSocketErrorMiddleware:
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
if scope["type"] == "websocket":
websocket = WebSocket(scope, receive, send)
try:
await self.app(scope, receive, send)
except WebSocketException as exc:
await websocket_exception_handler(websocket, exc)
except Exception:
error = traceback.format_exc()
logger.error(f"Unexpected error: {error}")
await websocket.close(code=1011, reason="Internal Server Error")
else:
await self.app(scope, receive, send)

Two catch branches:

  1. WebSocketExceptionwebsocket_exception_handler → sends a close frame with the exception’s code and reason.
  2. Any other Exception → logs the traceback and sends a close frame with code 1011 (Internal Server Error) and a generic reason.
async def websocket_exception_handler(
websocket: WebSocket, exc: WebSocketException
) -> None:
error = traceback.format_exc()
logger.error(f"WebSocket error: {error}")
await websocket.close(code=exc.code, reason=str(exc))
flowchart TD
    WS["WebSocket connection"] --> MW["WebSocketErrorMiddleware"]
    MW --> TRY["await self.app(scope, receive, send)"]
    TRY --> OK["Success → normal WS lifecycle"]
    TRY --> WSE{"WebSocketException?"}
    WSE -- Yes --> HANDLER["websocket_exception_handler"]
    HANDLER --> CLOSE1["websocket.close(code=exc.code, reason=str(exc))"]
    WSE -- No --> OTHER{"Any other Exception?"}
    OTHER -- Yes --> LOG["logger.error(traceback)"]
    LOG --> CLOSE2["websocket.close(code=1011, reason='Internal Server Error')"]
    OTHER -- No --> OK
CodeNameWhen to use
1000Normal ClosureConnection completed successfully
1001Going AwayServer shutting down, client navigating away
1008Policy ViolationAuthentication failure, rate limiting
1011Internal Server ErrorUnexpected server-side error
1013Try Again LaterServer temporarily overloaded
# Raise from a WebSocket handler
raise WebSocketException(code=1008, reason="Authentication required")
# Close with no reason (defaults to empty string)
raise WebSocketException(code=1000)

10. Content Negotiation in Error Responses

Section titled “10. Content Negotiation in Error Responses”

The handle_404_error handler is the only built-in handler that performs content negotiation. All other handlers return JSON unconditionally.

  1. Read the Accept header from the request.
  2. If it contains text/html or application/xhtml+xml → return an HTML page.
  3. If request.accepts_json is True → return JSON.
  4. Otherwise → return plain text.

File: core/sillo/handlers/not_found.py (lines 12 to 59)

def generate_html_page(title: str, message: str) -> str:
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
body {{ font-family: Arial, sans-serif; text-align: center; margin: 50px; color: #333; }}
h1 {{ font-size: 48px; color: #d9534f; }}
p {{ font-size: 18px; margin-top: 10px; }}
</style>
</head>
<body>
<h1>{title}</h1>
<p>{message}</p>
</body>
</html>"""

The page is self-contained (no external CSS/JS dependencies) and uses a minimal, centered layout.

Accept HeaderResponse FormatStatus
text/htmlHTML page404
application/xhtml+xmlHTML page404
application/jsonJSON object404
*/*JSON object404
(empty/missing)JSON object404
text/plainPlain text404
image/pngPlain text404

File: core/sillo/validation/errors.py (lines 15 to 65)

The prefix_errors function converts Pydantic ValidationError instances into location-prefixed error dictionaries:

def prefix_errors(
exc: ValidationError,
location: str,
*,
alias_map: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for err in exc.errors():
loc: Sequence[Any] = err.get("loc", ())
first = loc[0] if loc else None
if alias_map and isinstance(first, str) and first in alias_map:
loc = (alias_map[first], *loc[1:])
item = {
"loc": [location, *loc],
"msg": err.get("msg", ""),
"type": err.get("type", ""),
}
if "input" in err:
item["input"] = err["input"]
out.append(item)
return out

Example transformation:

Pydantic reports: {"loc": ("page",), "msg": "value is not a valid integer", ...}

After prefix_errors(exc, "query"): {"loc": ["query", "page"], "msg": "...", ...}

The alias_map parameter handles the case where a Python field name differs from the wire name (e.g., due to Pydantic aliases).

ExceptionSourceHTTP StatusWhen raised
RequestValidationErrorClient input422Request data fails validation markers (Query, Path, Body, etc.)
ResponseValidationErrorServer output500Handler return value violates response_model

Key distinction: RequestValidationError is a client error (the client sent bad data). ResponseValidationError is a server error (the application produced bad output). They map to different HTTP status codes to make this distinction clear to API clients.

RequestValidationError accumulates errors across all request locations in a single exception. A single request can have bad query parameters AND a malformed body, producing a single 422 response with all errors listed:

{
"detail": [
{"loc": ["query", "page"], "msg": "value is not a valid integer", "type": "type_error.integer"},
{"loc": ["body", "name"], "msg": "field required", "type": "value_error.missing"},
{"loc": ["path", "team_id"], "msg": "value is not a valid integer", "type": "type_error.integer"}
]
}

This avoids the N+1 round-trip problem where clients fix one error per request.


All exception handlers must conform to the ExceptionHandlerType defined in core/sillo/types.py (line 39):

ExceptionHandlerType = Callable[[Request, Response, Exception], Response]

In practice, handlers are async callables with this signature:

async def my_handler(
request: Request,
response: Response,
exc: SomeException,
) -> Response:
...

Parameters:

ParameterTypeDescription
requestRequestThe incoming HTTP request. Provides access to headers, URL, method, scope, and the application instance.
responseResponse (Responder)A response factory. Call .json(), .html(), .text(), or .empty() to produce the HTTP response.
excException subclassThe caught exception. Handlers should type-hint this to the specific exception class they handle.

Return: A Response object. The middleware sends this back to the client.

MethodUse case
response.json(data, status_code=..., headers=...)JSON responses (most common)
response.html(html_string, status_code=...)HTML responses (404 page)
response.text(text, status_code=...)Plain text responses
response.empty(status_code=..., headers=...)Empty body (204, 304)

# Status-code based (goes into _status_handlers)
app.add_exception_handler(429, my_rate_limit_handler)
# Class-based (goes into _exception_handlers)
app.add_exception_handler(InsufficientCreditsError, my_credits_handler)
from sillo.record.exceptions import register_db_exception_handlers
app = SilloApp()
register_db_exception_handlers(app)
# Override the default 404 handler
async def custom_404(request, response, exc):
return response.json({"error": "custom not found"}, status_code=404)
app.add_exception_handler(NotFoundException, custom_404)
# Catch any unhandled exception (risky — use with caution)
async def catch_all(request, response, exc):
logger.exception("Unhandled exception")
return response.json({"error": "Internal Server Error"}, status_code=500)
app.add_exception_handler(Exception, catch_all)

13.5 Status-Code Override vs Class Override

Section titled “13.5 Status-Code Override vs Class Override”
# This catches ALL 404 responses (including NotFoundException)
app.add_exception_handler(404, my_404_handler)
# This only catches NotFoundException specifically
app.add_exception_handler(NotFoundException, my_404_handler)

The status-code handler takes priority (checked first in wrap_http_exceptions).


flowchart TD
    CLIENT["Client Request"] --> BRIDGE["ASGIRequestResponseBridge"]
    BRIDGE --> MW_CHAIN["Middleware Chain"]
    MW_CHAIN --> EM["ExceptionMiddleware.__call__"]

    EM --> FAST{"Both registries\nempty?"}
    FAST -- Yes --> NEXT["call_next() → Route Handler"]
    FAST -- No --> WRAP["wrap_http_exceptions()"]

    WRAP --> TRY["try: await call_next()"]
    TRY --> SUCCESS["✅ Return Response"]
    TRY --> EXC["❌ Exception raised"]

    EXC --> IS_HTTP{"isinstance(exc,\nHTTPException)?"}

    IS_HTTP -- Yes --> STATUS{"exc.status_code\nin _status_handlers?"}
    STATUS -- Yes --> SH["Execute status handler"]
    SH --> SRESP["Return Response"]

    STATUS -- No --> MRO_HTTP["_lookup_exception_handler\n(exception_handlers, exc)"]
    IS_HTTP -- No --> MRO_OTHER["_lookup_exception_handler\n(exception_handlers, exc)"]

    MRO_HTTP --> FOUND_HTTP{"Handler found?"}
    MRO_OTHER --> FOUND_OTHER{"Handler found?"}

    FOUND_HTTP -- Yes --> EXEC_HTTP["Execute handler"]
    EXEC_HTTP --> HRESP["Return Response"]

    FOUND_OTHER -- Yes --> EXEC_OTHER["Execute handler"]
    EXEC_OTHER --> HRESP2["Return Response"]

    FOUND_HTTP -- No --> LOG_HTTP["logger.error(traceback)\nRe-raise"]
    FOUND_OTHER -- No --> LOG_OTHER["logger.error(traceback)\nRe-raise"]

    LOG_HTTP --> SERVE["ServerErrorMiddleware\n→ 500 response"]
    LOG_OTHER --> SERVE

    NEXT --> ROUTE_OK["Handler returns Response"]
    ROUTE_OK --> SUCCESS

    style EM fill:#f9f,stroke:#333
    style WRAP fill:#f9f,stroke:#333
    style SERVE fill:#fbb,stroke:#333
flowchart TD
    INPUT["_lookup_exception_handler(exc_handlers, exc)"] --> MRO["Get type(exc).__mro__"]
    MRO --> LOOP["For each cls in MRO:"]
    LOOP --> CHECK{"cls in exc_handlers?"}
    CHECK -- Yes --> RETURN["return exc_handlers[cls]"]
    CHECK -- No --> NEXT_CLS{"More classes\nin MRO?"}
    NEXT_CLS -- Yes --> LOOP
    NEXT_CLS -- No --> NONE["return None"]
flowchart LR
    subgraph ExceptionMiddleware
        subgraph _status_handlers ["_status_handlers (int keys)"]
            SH_EMPTY["(empty by default)"]
        end
        subgraph _exception_handlers ["_exception_handlers (class keys)"]
            EH1["HTTPException → http_exception"]
            EH2["AuthenticationFailed → AuthErrorHandler"]
            EH3["NotFoundException → handle_404_error"]
            EH4["ValidationError → pydantic_validation_error_handler"]
            EH5["RequestValidationError → request_validation_error_handler"]
            EH6["ResponseValidationError → response_validation_error_handler"]
        end
    end
flowchart LR
    subgraph After register_db_exception_handlers
        subgraph _exception_handlers ["_exception_handlers (class keys)"]
            EH1["HTTPException → http_exception"]
            EH2["AuthenticationFailed → AuthErrorHandler"]
            EH3["NotFoundException → handle_404_error"]
            EH4["ValidationError → pydantic_validation_error_handler"]
            EH5["RequestValidationError → request_validation_error_handler"]
            EH6["ResponseValidationError → response_validation_error_handler"]
            DB1["DoesNotExist → handle_does_not_exist"]
            DB2["IntegrityError → handle_integrity_error"]
            DB3["Tortoise ValidationError → handle_validation_error"]
            DB4["OperationalError → handle_operational_error"]
        end
    end

HTTPException.__init__ and NotFoundException.__init__ use mutable default arguments (headers: dict = {}). This is a known Python anti-pattern, but the framework only reads the dict, never mutates it. If you need to pass headers, always pass an explicit dict:

# Good
raise HTTPException(401, headers={"WWW-Authenticate": "Bearer"})
# Technically works but risky if anyone mutates the default
raise HTTPException(401)

Status-code handlers are checked BEFORE class-based handlers. This means:

# This handler fires for ALL 404s, even NotFoundException
app.add_exception_handler(404, my_handler)
# This handler is IGNORED when the status-code handler matches
app.add_exception_handler(NotFoundException, other_handler)

To override the NotFoundException handler without affecting other 404s, use the class-based registration only (no status-code registration).

When no handler is found, wrap_http_exceptions logs the traceback and re-raises. This means the exception propagates to the ASGI layer (ServerErrorMiddleware), which produces a generic 500 response. In debug mode, the 500 page includes the full traceback.

15.4 Pydantic ValidationError vs Tortoise ValidationError

Section titled “15.4 Pydantic ValidationError vs Tortoise ValidationError”

These are different classes from different packages:

  • pydantic.ValidationError: handled by pydantic_validation_error_handler (422)
  • tortoise.exceptions.ValidationError: handled by handle_validation_error (422)

Both produce 422 responses but with different JSON shapes. The MRO lookup correctly distinguishes them because they share no common base class (other than Exception).

15.5 WebSocket Exceptions Don’t Go Through ExceptionMiddleware

Section titled “15.5 WebSocket Exceptions Don’t Go Through ExceptionMiddleware”

WebSocketException is caught by WebSocketErrorMiddleware, not by ExceptionMiddleware. The two middlewares operate at different levels of the ASGI stack:

  • WebSocketErrorMiddleware works at the raw ASGI level (scope, receive, send).
  • ExceptionMiddleware works at the sillo middleware level (request, response, call_next).

15.6 ResponseValidationError Is Intentionally Vague

Section titled “15.6 ResponseValidationError Is Intentionally Vague”

The response_validation_error_handler deliberately returns a generic error message. The actual validation errors are logged server-side but NOT included in the response. This prevents leaking internal data that the response model was supposed to filter out.

In wrap_http_exceptions, the variable handler is initialized to None and checked with if handler is None. This is NOT the same as if not handler. The latter would also be True for falsy handler objects. The explicit is None check is intentional.


import pytest
from unittest.mock import AsyncMock, MagicMock
from sillo.exceptions import NotFoundException
from sillo.handlers.not_found import handle_404_error
@pytest.mark.asyncio
async def test_handle_404_json():
request = MagicMock()
request.headers = {"accept": "application/json"}
request.accepts_json = True
request.scope = {}
response = MagicMock()
response.json = MagicMock(return_value="mock_response")
exc = NotFoundException("Resource not found")
result = await handle_404_error(request, response, exc)
response.json.assert_called_once()
call_args = response.json.call_args
assert call_args[1]["status_code"] == 404
from sillo.testing import TestClient
def test_custom_exception_handler():
app = SilloApp()
@app.get("/fail")
async def fail(request, response):
raise HTTPException(400, detail="Bad request")
client = TestClient(app)
resp = client.get("/fail")
assert resp.status_code == 400
assert resp.json() == "Bad request"
def test_mro_resolution():
"""Verify that the most specific handler is found."""
from sillo.exception_handler import _lookup_exception_handler
handlers = {
HTTPException: handler_a,
AuthenticationFailed: handler_b,
}
# AuthenticationFailed is more specific
exc = AuthenticationFailed()
assert _lookup_exception_handler(handlers, exc) is handler_b
# Bare HTTPException matches the base handler
exc = HTTPException(400)
assert _lookup_exception_handler(handlers, exc) is handler_a
# An unrelated exception matches Exception if registered
handlers[Exception] = handler_c
exc = ValueError()
assert _lookup_exception_handler(handlers, exc) is handler_c

FileLinesPurpose
core/sillo/exceptions.py242HTTPException, NotFoundException, WebSocketException
core/sillo/exception_handler.py407ExceptionMiddleware, wrap_http_exceptions, _lookup_exception_handler, all default handlers
core/sillo/auth/exceptions.py197AuthException, AuthenticationFailed, PermissionDenied, AuthErrorHandler
core/sillo/handlers/not_found.py162handle_404_error, generate_html_page, _debug_enabled, _prefers_html
core/sillo/validation/errors.py126RequestValidationError, ResponseValidationError, prefix_errors
core/sillo/validation/__init__.py89Re-exports for validation package
core/sillo/record/exceptions.py110handle_does_not_exist, handle_integrity_error, handle_validation_error, handle_operational_error, register_db_exception_handlers
core/sillo/websockets/errors.py40websocket_exception_handler, WebSocketErrorMiddleware
core/sillo/types.py41ExceptionHandlerType type alias

Appendix A: Complete Handler Registration Sequence

Section titled “Appendix A: Complete Handler Registration Sequence”
sequenceDiagram
    participant Dev as Developer
    participant App as SilloApp
    participant EM as ExceptionMiddleware
    participant DB as record/exceptions

    Dev->>App: SilloApp()
    App->>EM: ExceptionMiddleware.__init__()
    Note over EM: Registers 6 default handlers:<br/>HTTPException, AuthenticationFailed,<br/>NotFoundException, ValidationError,<br/>RequestValidationError,<br/>ResponseValidationError

    Dev->>App: register_db_exception_handlers(app)
    App->>DB: register_db_exception_handlers(app)
    DB->>EM: add_exception_handler(DoesNotExist, ...)
    DB->>EM: add_exception_handler(IntegrityError, ...)
    DB->>EM: add_exception_handler(ValidationError, ...)
    DB->>EM: add_exception_handler(OperationalError, ...)

    Note over EM: _exception_handlers now has<br/>10 entries total

    Dev->>App: app.add_exception_handler(429, rate_limit)
    App->>EM: add_exception_handler(429, rate_limit)
    Note over EM: _status_handlers now has<br/>1 entry

Appendix B: Exception Response Format Reference

Section titled “Appendix B: Exception Response Format Reference”
"Bad request"
"Authentication failed"
{
"status": 404,
"error": "Not Found",
"message": "The page you are looking for does not exist."
}
{
"status": 404,
"error": "Not Found",
"message": "User with id 42 not found",
"traceback": "Traceback (most recent call last):\n ..."
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Not Found</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin: 50px; color: #333; }
h1 { font-size: 48px; color: #d9534f; }
p { font-size: 18px; margin-top: 10px; }
</style>
</head>
<body>
<h1>404 - Not Found</h1>
<p>The page you are looking for does not exist.</p>
</body>
</html>
404 - Not Found
The page you are looking for does not exist.
{
"error": "Validation Error",
"errors": {
"name": "field required",
"address": {
"city": "field required"
}
}
}
{
"detail": [
{
"loc": ["query", "page"],
"msg": "value is not a valid integer",
"type": "type_error.integer"
}
]
}
{
"error": "Internal Server Error",
"detail": "Response validation failed"
}
{
"error": "Not Found",
"detail": "User matching query does not exist."
}
{
"error": "Conflict",
"detail": "UNIQUE constraint failed: user.email"
}
{
"error": "Service Unavailable",
"detail": "Database unavailable"
}