"""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}, )