refactor: every API error is one ErrorResponse, and the frontend uses the generated type instead of ApiError
Third of the stacked frontend PRs, on top of #360. It replaces the planned no-reflection PR, which was dropped.
The backend answered errors in four shapes. The domain handler sent {detail, type}. FastAPI's validation errors were {detail: [...]}. Its own HTTP errors, the size limit and the rate limit sent a bare {detail}, the rate limit with two extras. A crash came back as plain text. None of them carried the status, so the frontend wrapped every failure in a hand-written ApiError to keep it, and had instanceof checks wherever it needed one.
Every error now is one ErrorResponse, {status, detail}: the status and detail members of RFC 9457 problem details. Four handlers in app/core/exceptions/handlers.py cover domain errors, FastAPI's HTTPException, request validation (one line of field: message pairs) and anything unexpected (a 500, which Starlette still re-raises for the log). The rate-limit, CSRF and size-limit middlewares send the same body, and the 429's retry information stays in the Retry-After and X-RateLimit-* headers. create_app declares ErrorResponse as every route's default response. That also keeps FastAPI from documenting HTTPValidationError, so every error in the spec, and in the generated client, is the one type. The default names the schema rather than the model, so the SSE routes document their JSON errors as application/json instead of text/event-stream. The domain exceptions are unchanged.
On the frontend, api-error.ts and getErrorMessage are gone:
- TanStack Query's
defaultErroris registered as the generatedErrorResponse, soquery.error,mutation.error, the retry policy and the cache handlers are all typed. - Code reads
error.statusanderror.detaildirectly. That covers the 401 handling, the save that falls back to create on a 404, and the forms' inline messages. - The error interceptor only fills in failures without such a body. A proxy's error page becomes
{status, detail: "HTTP <status>"}, and no response at all becomes status 0, which the retry policy and the network toast use. mockApi(fn).err()now takes anErrorResponse, so each test fixture states the status it's about.
The API change is visible: errors gain status and lose type. A 422's detail is a string instead of FastAPI's list, and the 429 body drops retry_after and reset_at. Only this frontend reads them.
Backend: ruff, mypy, the orphan check, 546 unit tests including a new one that pins the body for every kind of error, and the e2e suite on a local stack: 445 passed, and the 20 failures are the same k3s-dependent saga and notification tests that fail on main. Frontend: lint, stylelint, the format check, svelte-check, coverage with 959 tests, and the build. Regenerating the spec and the client gives no diff.
Summary by cubic
Every API error is now a single ErrorResponse body — {status, detail}, the status and detail members of RFC 9457 problem details — and the frontend uses the generated type instead of the hand-written ApiError wrapper.
- The domain, HTTP, validation and unexpected-error handlers, plus the rate-limit, CSRF and size-limit middlewares, all send the same body; validation errors become one line of
field: messagepairs. ErrorResponseis declared as every route's default response, which drops FastAPI'sHTTPValidationErrorfrom the spec and the generated client.- The error interceptor now only fills in failures without such a body: a proxy's error page becomes
{status, detail: "HTTP <status>"}, and no response at all becomes status 0, which the retry policy and the network toast rely on. mockApi(fn).err()takes anErrorResponse, so test fixtures state the status they're about.
Migration
Errors gain status and lose type; a 422's detail is now a string instead of a list, and the 429 body drops retry_after and reset_at (that information stays in the Retry-After and X-RateLimit-* headers). Only this frontend reads error bodies.