Skip to content

feat(server): uniform error handling — domain errors to HTTP with stable codes (#31) - #96

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat/31-error-handling
Jul 27, 2026
Merged

feat(server): uniform error handling — domain errors to HTTP with stable codes (#31)#96
JArmandoAnaya merged 1 commit into
mainfrom
feat/31-error-handling

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #31. M3 task 2 of 14.

Every failure the API can produce — a kernel domain error, a framework HTTPException, a request-validation failure, or an unhandled bug — is rendered by one function into one ErrorBody {code, message, detail?}, declared in openapi.json's components and applied to every route.

Branch on code, not on the status

DESTRUCTIVE_SCHEMA_CHANGE and SCHEMA_CHANGE_WOULD_ORPHAN are both 409. The first is retryable with allow_destructive=true; the second has no override at all. A client that saw 409 and retried with the flag would loop forever against the second — which is exactly the loop SchemaChangeWouldOrphan's docstring warns about. Over HTTP the code is the only thing carrying that distinction.

Three rules place a domain error, not fifty

  • 404 — the caller named something that is not there.
  • 409 — the request is well-formed; the resource's state refuses it, and the remedy is to change that state and resubmit the identical request.
  • 422 — the payload itself is wrong.

Plus 503 for the one transient error and 500 for what a caller cannot fix.

The calls worth reviewing

Each is commented in ERROR_RULES with the docstring sentence that decides it.

  • ConstraintViolated → 500, opaque. Its message is str(exc.orig) — raw SQLite text naming our own tables. A 409 would publish the schema to every client. Services pre-check rather than relying on the write to fail, and the two that translate a constraint each own exactly one index, so anything reaching the boundary is a guard nobody wrote.
  • NoSplitRecipe → 404, not 409. A release is immutable, so its state will never change and "resolve the conflict and resubmit" is a promise that cannot be kept. NO_SPLIT_RECIPE vs RELEASE_NOT_FOUND is the case codes exist for.
  • UnserializableManifest → 409, not 422. The request body is valid; the defect is in state stored long before, and the remedy is to fix the annotation and publish again.
  • MediaToolUnavailable → 500, not 503. 503 promises transience, and no amount of retrying installs ffmpeg. Its message is exposed, because the install hint is the whole reason the message exists.
  • UnsupportedMedia → 422, not 415. Every raise site reads a file on disk during ingest; 415 is about the request's own Content-Type.
  • WorkspaceBusy → 503 with Retry-After: 5, matched to the store's busy timeout. This is kernel: SQLite concurrency posture — WAL, busy_timeout, OperationalError → domain error (closes the "database is locked" gap before background ingest) #80's first caller. A shorter hint would aim a retry storm at the contention being reported.

Opacity: 5xx by default, three named opt-outs

A 5xx body carries an incident_id and a generic sentence; the real message and traceback go to the log under that id. WORKSPACE_BUSY, WORKSPACE_FORMAT_TOO_NEW and MEDIA_TOOL_UNAVAILABLE opt out because their message is the remedy. A mapped 5xx keeps its own code; an unmapped exception gets INTERNAL_ERROR, so the two are told apart in a log without reading the message.

A test caught a real hole here mid-implementation: MediaError.__str__ is f"{name}: {reason}", so keeping the file's path out of detail while message carried it hid nothing. The API's message for a media error is now exc.reason alone — the kernel already separated the two ("reason never repeats the name"), and this takes the half that is safe to publish.

Two traps

  • The handler is registered on starlette.exceptions.HTTPException, not fastapi's. The router raises the Starlette class for an unknown path and for a 405, and FastAPI's is a subclass — keying on the subclass would leave those two answering {"detail": "Not Found"} while everything else answered ErrorBody. There is a test for it.
  • Handlers are annotated (Request, Exception) and narrow inside. Starlette's ExceptionHandler type takes Exception; a narrower annotation is contravariance-incompatible and fails mypy src/visionset, which CI runs. No # type: ignore.

The app factory

create_app() now exists, and app = create_app() stays module-level because scripts/export_openapi.py imports it by name. /health moves onto an APIRouter — the pattern #27 extends.

responses= is applied at app level rather than per route. Declaring 422 there displaces FastAPI's generated HTTPValidationError, keeping that model — and the second error shape it implies — out of openapi.json entirely. That is what makes "one error body, used everywhere" true by construction instead of by every future route remembering; a test asserts it never comes back.

Tests

tests/server/test_errors.py, 28 tests. Two structural ones carry the acceptance criterion and cannot drift:

  • Exhaustiveness — enumerates vars(kernel.errors) (not __subclasses__(), which only sees imported classes and would depend on collection order) and asserts exact equality with ERROR_RULES. A new kernel error fails the suite until somebody maps it deliberately.
  • The literal table — one written-out {ClassName: (status, code)} of all 49, so a status change is a readable diff.

Codes are literals rather than derived from class names: a code is a public contract keyed to a Python identifier, and deriving it means a pure refactor rename silently breaks every client while passing every test. A test asserts each literal still matches its class name today, with a RENAMED map for when one genuinely changes.

What did NOT change

FORMAT_VERSION stays 10 — no migration (M3's only one belongs to #25; a second appearing here would be the signal that logic is leaking upward). VERSION stays 0.0.1.dev0. No new dependency, no new kernel error, no new service, port or event. _auth_provider, require_token and EnvTokenAuthProvider are untouched — #25 owns them; the existing 401 simply starts speaking ErrorBody, challenge header intact.

919 tests, up from 896.

Checks

uv run ruff format .     120 files left unchanged
uv run ruff check .      All checks passed!
uv run mypy src/visionset            Success: no issues found in 62 source files
uv run mypy src/visionset/kernel     Success: no issues found in 51 source files
uv run lint-imports                  Contracts: 2 kept, 0 broken.
uv run pytest                        919 passed
uv run python scripts/export_openapi.py   no drift after commit

Verified by hand against a running server: /health unchanged, an unknown path returns {"code":"NOT_FOUND",...}, and components.schemas is exactly ["ErrorBody"].

Next: #25 — the real AuthProvider: a persisted adapter in kernel/adapters/, issuance as a TokenService, and M3's only migration.

…ble codes (#31)

Every failure the API can produce — a kernel domain error, a framework
HTTPException, a request-validation failure, or an unhandled bug — is now
rendered by one function into one ErrorBody {code, message, detail?}, declared
in openapi.json's components and applied to every route.

Clients branch on `code`, never on the status. DestructiveSchemaChange and
SchemaChangeWouldOrphan are both 409, and only the first is retryable with a
flag — a client branching on the status alone would loop against the second,
which is the loop that error's own docstring warns about.

Also introduces create_app(), keeping visionset.server.main:app importable for
scripts/export_openapi.py, and gives #80's WorkspaceBusy its first caller.
@JArmandoAnaya
JArmandoAnaya merged commit 4217ce1 into main Jul 27, 2026
3 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/31-error-handling branch July 27, 2026 23:31
JArmandoAnaya added a commit that referenced this pull request Aug 21, 2026
…ble codes (#31) (#96)

Every failure the API can produce — a kernel domain error, a framework
HTTPException, a request-validation failure, or an unhandled bug — is now
rendered by one function into one ErrorBody {code, message, detail?}, declared
in openapi.json's components and applied to every route.

Clients branch on `code`, never on the status. DestructiveSchemaChange and
SchemaChangeWouldOrphan are both 409, and only the first is retryable with a
flag — a client branching on the status alone would loop against the second,
which is the loop that error's own docstring warns about.

Also introduces create_app(), keeping visionset.server.main:app importable for
scripts/export_openapi.py, and gives #80's WorkspaceBusy its first caller.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: uniform error handling — domain errors → HTTP with stable codes and detail (part of the contract)

1 participant