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