Errors
Three families, one per ring, and one place that maps them to HTTP.
Domain errors are named after the rule
class TournamentAlreadyStarted(DomainError):
message = "Tournament has already started."
Not TournamentError("already started"). A specific type means:
- tests assert on the type, not on a string;
- the HTTP response carries a stable, machine-readable
errorfield ({"error": "TournamentAlreadyStarted", "message": "..."}); - the API layer can map one rule to a different status code if 422 is wrong for it,
with
register_error(app, TournamentAlreadyStarted, 409).
The ...Error suffix is deliberately not used for domain rules; ruff's N818 is disabled
for that reason.
Application errors describe the request
TournamentNotFound(NotFoundError) is raised by use cases, never by the domain: the domain
does not know that ids can be looked up. ForbiddenError (the actor may not do this) maps
to 403. ConflictError (a stale write, see ADR-010)
maps to 409, as does any other ApplicationError by default.
Authentication failures are different: they happen before any use case runs, in the HTTP
adapter (shared/http/auth.py), as HTTPException(401). The handler in errors.py wraps
them in the same envelope and keeps the WWW-Authenticate header.
Nothing else crosses the boundary
- Repositories raise nothing domain-specific. A missing row is
None; the use case turns it intoTournamentNotFound. - Infrastructure exceptions (
sqlalchemy.exc.*, connection errors) are not translated: they are bugs or outages, they become a logged 500, and the transaction rolls back. - Pydantic validation errors stay FastAPI's native 422 with a
detaillist, because clients and tooling already understand that format and it carries field locations. - Everything else that FastAPI/Starlette raise on their own - unknown route (404), wrong
method (405, with its
Allowheader) - is rendered in the envelope too. - An unexpected exception is a
500in the envelope, with the request id both in the response header and on the log line, so the two can be matched.
tests/http/test_errors.py pins every row of this contract.
Validation: shape vs rules
| Kind | Where | Example |
|---|---|---|
| Shape | Pydantic schema (http/schemas.py) | rounds must be an int; kind must be round or bracket |
| Rule | Domain (__post_init__, methods) | rounds must be 1-20; the first phase has no cut; can't start twice |
A rule belongs to the domain even when it is easy to express in Pydantic, because the domain must hold on every entry path (HTTP today, a CSV import tomorrow). See ADR-007.