patristic-be / src /api /errors.py
Mario33333's picture
deploy: reader-access security hardening (feature/audiobook@06a5ed8)
7c2d250 verified
Raw
History Blame Contribute Delete
9.24 kB
"""Typed API errors that serialize to the canonical envelope.
CONTRACT.md §8 enumerates the canonical codes. Stage 1 shipped the base
shape + ``InternalError``; Stage 2 added ``Unauthenticated`` /
``Forbidden``; Stage 3 fills in the rest of the codes the read-only
routers need to raise (``BookNotFound``, ``QueryRunNotFound``,
``NotIndexed``, ``BadRequest``).
Pattern: each error type extends :class:`ApiError` and sets a default
``code``/``status_code``. Routers raise them with the typed kwargs they
care about (e.g. ``raise BookNotFound(book_id="deskolia_v3")``) and the
global exception handler in ``src/api/app.py`` serializes them to an
``ErrorResponse`` body. FastAPI keeps its default ``HTTPException``/422
behavior for unrelated cases — though Pydantic validation failures are
re-shaped via the validation handler in ``app.py`` so the wire envelope
stays canonical.
Why typed constructors per code (e.g. ``BookNotFound(book_id=...)``)
rather than free-form ``ApiError("BOOK_NOT_FOUND", ...)``: the typed
shape makes router call-sites self-documenting and prevents drift in the
``details`` keys the frontend relies on — those keys must be camelCase on
the wire (CONTRACT.md §8) and the constructor centralises the choice.
"""
from __future__ import annotations
from typing import Any
from src.api.dto.common import ErrorResponse
class ApiError(Exception):
"""Base class for typed errors that translate to ``ErrorResponse``.
Subclasses set ``code`` and ``status_code`` as class attributes. The
``message`` and ``details`` come from the constructor.
"""
code: str = "INTERNAL_ERROR"
status_code: int = 500
def __init__(
self,
message: str,
*,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.details = details
def to_envelope(self) -> ErrorResponse:
return ErrorResponse(code=self.code, message=self.message, details=self.details)
class InternalError(ApiError):
"""Unexpected condition; details may be empty. Maps to 500."""
code = "INTERNAL_ERROR"
status_code = 500
class Unauthenticated(ApiError):
"""No / expired / unknown session cookie. Maps to 401.
Raised by :func:`src.api.deps.current_user` when the request can't be
tied to a logged-in user. The frontend's ``ApiClient`` catches the
``UNAUTHENTICATED`` code and redirects to ``/login?next=...``.
"""
code = "UNAUTHENTICATED"
status_code = 401
class Forbidden(ApiError):
"""Logged in, but the action requires a role you don't have. Maps to 403.
Raised by :func:`src.api.deps.require_admin` when a viewer attempts an
admin-only action. The CSRF middleware also raises this when a
mutating request is missing the ``X-Requested-With`` header.
"""
code = "FORBIDDEN"
status_code = 403
class BookNotFound(ApiError):
"""Book id does not exist in the inventory. Maps to 404.
Carries ``details.bookId`` (camelCase on the wire) so the frontend can
surface a useful "this book isn't in your library" message instead of
a generic 404.
"""
code = "BOOK_NOT_FOUND"
status_code = 404
def __init__(self, *, book_id: str, message: str | None = None) -> None:
super().__init__(
message or f"No book with id {book_id!r}.",
details={"bookId": book_id},
)
class QueryRunNotFound(ApiError):
"""``GET /query/runs/{runId}`` for an unknown id. Maps to 404.
``runId`` is the ``query_history.id`` integer; we keep it as int on
the wire under ``details.runId`` so the FE can echo it back in a
"rerun this query" CTA without parsing.
"""
code = "QUERY_RUN_NOT_FOUND"
status_code = 404
def __init__(self, *, run_id: int, message: str | None = None) -> None:
super().__init__(
message or f"No committed query run with id {run_id}.",
details={"runId": run_id},
)
class NotIndexed(ApiError):
"""Tried to query/inspect chunks but no chunks exist. Maps to 409.
Raised by routers that depend on an indexed corpus when the active
collection is empty (the Search page's empty-state CTA points at
``/library/add``). The frontend handles 409 NOT_INDEXED with a
"no books are indexed yet" banner instead of a toast.
"""
code = "NOT_INDEXED"
status_code = 409
def __init__(
self,
message: str = "No indexed books to query yet.",
*,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message, details=details)
class BadRequest(ApiError):
"""Validation failure. Maps to 400.
Used for *semantic* validation a router runs after Pydantic accepts
the body — e.g. "groupBy=foo isn't a valid enum value", "page < 1
isn't allowed". Pure Pydantic schema failures still surface as 422
via FastAPI's default handler (we keep that behavior because the
frontend's typed client doesn't distinguish 400 from 422 anyway —
both throw ``ApiError``).
"""
code = "BAD_REQUEST"
status_code = 400
class RateLimited(ApiError):
"""Too many paid requests from this user in a short window. Maps to 429.
Raised by the per-user token-bucket limiter in :mod:`src.api.limits`
before a paid endpoint (query / ingest / OCR sample / eval) does any
work. ``details.retryAfterSeconds`` tells the FE how long to wait; we
also set the ``Retry-After`` header in the app-level handler.
"""
code = "RATE_LIMITED"
status_code = 429
def __init__(
self,
message: str = "You're sending paid requests too quickly. Please wait a moment.",
*,
retry_after_seconds: int | None = None,
) -> None:
super().__init__(
message,
details={"retryAfterSeconds": retry_after_seconds}
if retry_after_seconds is not None
else None,
)
class SpendCapExceeded(ApiError):
"""A configured USD spend ceiling has been hit. Maps to 429.
The owner's #1 guardrail: checked against the ``llm_calls`` ledger
(global/day) and ``query_history`` (per-user/day) before any paid op.
Once a cap is reached, every paid endpoint refuses until the UTC day
rolls over (or the operator raises the cap in ``config.yaml > api``).
``details`` carries the scope + the cap that tripped so the FE can show
a precise message instead of a generic error.
"""
code = "SPEND_CAP_EXCEEDED"
status_code = 429
def __init__(
self,
message: str,
*,
scope: str,
cap_usd: float,
spent_usd: float,
) -> None:
super().__init__(
message,
details={
"scope": scope,
"capUsd": round(cap_usd, 4),
"spentUsd": round(spent_usd, 4),
},
)
class UserNotFound(ApiError):
"""``/admin/users/{username}`` for an unknown DB account. Maps to 404.
Carries ``details.username`` (already lowerCamelCase-safe — a single
token) so the FE can echo which account was missing.
"""
code = "USER_NOT_FOUND"
status_code = 404
def __init__(self, *, username: str, message: str | None = None) -> None:
super().__init__(
message or f"No user named {username!r}.",
details={"username": username},
)
class UserExists(ApiError):
"""``POST /admin/users`` with a username that's already taken. Maps to 409.
Covers BOTH a duplicate ``app_users`` row AND a collision with a bootstrap
(env/TOML) account — we refuse to shadow an admin/viewer login id with a
DB reader account. ``details.username`` carries the conflicting id.
"""
code = "USER_EXISTS"
status_code = 409
def __init__(self, *, username: str, message: str | None = None) -> None:
super().__init__(
message or f"A user named {username!r} already exists.",
details={"username": username},
)
class BookExists(ApiError):
"""``POST /books`` URL/checksum collision. Maps to 409.
The wire envelope carries ``details.conflictWithBookId`` (camelCase per
CONTRACT.md §8) so the FE can render a "this book already exists — open
it" link without a second roundtrip. The most common trigger is a user
pasting the same source URL twice; the SHA-256 of the uploaded bytes is
the secondary collision key.
"""
code = "BOOK_EXISTS"
status_code = 409
def __init__(
self,
*,
conflict_with_book_id: str,
message: str | None = None,
) -> None:
super().__init__(
message or (
f"A book with this source URL or content already exists "
f"({conflict_with_book_id})."
),
details={"conflictWithBookId": conflict_with_book_id},
)