feat(api): add centralized exception-to-http mapping mechanism#513
feat(api): add centralized exception-to-http mapping mechanism#513abdulrafey1 wants to merge 10 commits into
Conversation
|
@claude-review |
|
Claude finished @abdulrafey1's task in 3m 17s —— View job PR Review: centralized exception-to-http mappingReviewed the full diff against
OverviewThe mechanism is sound: No blocking issues. No security, correctness, or performance concerns. A few minor suggestions below. Correctness ✅
Suggestions (non-blocking)1. Low — Fixed response shape / undocumented escape hatch. The public API only produces 2. Low — Leaking 3. Nit — 4. Doc — PR description drift. The description references Tests & Docs ✅Coverage is thorough: registration, duplicate rejection, MRO dispatch, rendered body, Overall: LGTM. Ready to merge once the description drift (#4) is trimmed; the rest are optional follow-ups. |
| async def handler(request: Request, exc: Exception) -> JSONResponse: | ||
| return JSONResponse(status_code=status_code, content={"detail": str(exc)}) | ||
|
|
||
| EXCEPTION_HANDLERS.add_item(exc_type, handler) |
There was a problem hiding this comment.
Suggestion (Low): The public API only supports the fixed {"detail": str(exc)} shape + a status code. The PR description advertises a status_handler / custom-handler path, but that isn't in the shipped code — so a plugin needing a custom body/headers has to reach into EXCEPTION_HANDLERS.add_item(exc_type, my_handler) directly. That escape hatch works (EXCEPTION_HANDLERS is in __all__) but is undocumented.
Consider either (a) documenting the add_item escape hatch in PLUGIN_GUIDE.md, or (b) adding a thin register_exception_handler overload that accepts a caller-supplied ExceptionHandler. Not blocking — the fixed shape covers the intended domain-exception use case.
|
reviewed with experimental sparkth-code-reviewer Review: exception-to-http mapping mechanism0 must-fix, 3 suggestions. No checklist (M1–M21) violations found. Core mechanism, tests, and docs are consistent with each other and with existing conventions ( Suggestions
Outside the corpus — PR description driftThe "Changes" section describes |
|
Thanks @regisb — addressed all three suggestions and the description drift. 1. 2. Docstring described another module's behavior ( 3. Competing exception→HTTP philosophies side-by-side ( PR description drift — rewrote the description to match the shipped diff: All green: This comment was written with the assistance of an LLM (Claude). |
hamza-56
left a comment
There was a problem hiding this comment.
These review comments were written with the assistance of an LLM (Claude).
| ) | ||
|
|
||
|
|
||
| def register_exception_handler(exc_type: type[Exception], status_code: int) -> None: |
There was a problem hiding this comment.
Nothing validates status_code, so a typo like 20 or 200 registers fine and only shows up later as a confusing runtime response. A quick if not 400 <= status_code <= 599: raise ValueError(...) at registration time would catch that early, and it doubles as documentation that this registry is meant for error statuses only.
| """ | ||
|
|
||
| async def handler(request: Request, exc: Exception) -> JSONResponse: | ||
| return JSONResponse(status_code=status_code, content={"detail": str(exc)}) |
There was a problem hiding this comment.
One thing that worries me here: the handled exception is never logged. Starlette's ExceptionMiddleware doesn't log exceptions that a handler renders, so once a type is mapped, its occurrences leave no trace in the logs. A spike of 409s in production would be hard to debug.
| """Map ``exc_type`` (and, via MRO, its subclasses) to HTTP ``status_code``. | ||
|
|
||
| Registers a handler on the global ``EXCEPTION_HANDLERS`` registry that renders the | ||
| exception as ``{"detail": str(exc)}``. A second registration for the same type raises |
There was a problem hiding this comment.
Since str(exc) goes to the client verbatim, whatever the service layer puts in the message becomes API-visible the moment the type is registered. Could we call out in the docstring that registered exception types must carry client-safe messages (no internal IDs, paths, or SQL)?
| async def handler(request: Request, exc: Exception) -> JSONResponse: | ||
| return JSONResponse(status_code=status_code, content={"detail": str(exc)}) | ||
|
|
||
| EXCEPTION_HANDLERS.add_item((exc_type, handler)) |
There was a problem hiding this comment.
The duplicate check guards the registry against itself, but not against FastAPI's built-ins: registering HTTPException or RequestValidationError here would silently override FastAPI's default handlers when assemble_app wires the registry, losing HTTPException.headers support and the standard 422 body shape. Probably worth rejecting starlette.exceptions.HTTPException subclasses in this function.
There was a problem hiding this comment.
Leaving this for @abdulrafey1 to review and consider.
| """ | ||
|
|
||
| async def handler(request: Request, exc: Exception) -> JSONResponse: | ||
| return JSONResponse(status_code=status_code, content={"detail": str(exc)}) |
There was a problem hiding this comment.
The registry stores opaque handler closures, so the status code is not introspectable after registration. That means tests can only assert that a type is registered, never the actual type to status contract (e.g. that WhitelistEntryAlreadyExists maps to 409), the duplicate-registration ValueError cannot report which status it conflicts with, and the {"detail": str(exc)} body shape gets baked in at registration time instead of at the wiring layer.
Have you considered storing data instead? Keep the registry as exception type to status code (plain int) and build the handler once inside _register_exception_handlers in sparkth/main.py. Registration stays one line for callers, the rendering policy lives in exactly one place, and tests can assert registered[SomeError] == 409.
There was a problem hiding this comment.
Leaving this for @abdulrafey1 to review and consider.
Add a central registry where core and plugins map a domain exception type to an HTTP handler: register_status (type->status shorthand), register_exception_handler (custom handler), and the status_handler helper. Registry ships empty; endpoint migration is out of scope (issue #493). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
assemble_app() now calls _register_exception_handlers, iterating the registry and wiring each type->handler onto the app so routes can raise domain exceptions and let Starlette's MRO dispatch translate them. The wiring hook is injectable for tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the sparkth/lib/exception_handlers.py entry to the Public Library list and document register_status / register_exception_handler in the plugin authoring guide (issue #493). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the gap where deleting the _register_exception_handlers call in assemble_app left the suite green. Inject a probe handler into the global registry (auto-reverted via monkeypatch) and assert assemble_app registers it on the app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate the exception→HTTP mechanism into dedicated packages: core/exception_handlers.py -> core/exceptions/handlers.py, and the lib facade plus domain classes into lib/exceptions/ (handlers.py + __init__.py). Update imports, docstrings, CLAUDE.md, the plugin guide, and move the test to tests/core/exceptions/test_handlers.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the exception→HTTP surface to a single register_exception_handler(exc_type,
status_code) that maps a class to a status rendered as {"detail": str(exc)}. Drop the
separate register_status/status_handler helpers, the custom-handler path, and the
injectable test hook — registration always targets the global EXCEPTION_HANDLERS. The
facade re-exports only that function; tests isolate via a snapshot/restore fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the domain-exception → HTTP mapping standard: raise HTTP-agnostic exceptions, register a 1-to-1 type→status mapping via register_exception_handler, and reserve in-route try/except for genuinely context-dependent statuses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Forward the EXCEPTION_HANDLERS registry through sparkth.lib.exceptions.handlers alongside register_exception_handler, keeping the registry reachable via the public facade. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
regisb's review flagged that TypeKeyedHook re-implemented a registry shape that KeyedItemHook already generalizes. Drop TypeKeyedHook and store (exc_type, handler) entries in KeyedItemHook keyed on exc_type; add KeyedItemHook.iter_values() so assemble_app can iterate them. Also scope register_exception_handler's docstring to this module (it no longer describes assemble_app's wiring). Step toward the full hook unification tracked in #522. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a43996e to
9497656
Compare
regisb's review noted HttpError/AuthenticationError/LMSRequestError carry status_code on the exception, contradicting the new 'a domain exception never carries an HTTP status' standard documented in CLAUDE.md. Add a module docstring marking them as legacy so a future contributor doesn't copy the status_code-on-exception pattern as current. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9497656 to
72ef068
Compare
Closes: Routes need a centralized, reusable way to map domain exceptions to HTTP responses
What
Add a centralized, reusable mechanism where core and plugins register how each domain exception type renders as an HTTP response, wired onto the app once at startup — so routes can raise HTTP-agnostic domain exceptions instead of repeating
try/except → HTTPExceptionat every call site.Changes
sparkth/core/exceptions/handlers.py(EXCEPTION_HANDLERS+register_exception_handler(exc_type, status_code), rendering{"detail": str(exc)}) with a public facade atsparkth/lib/exceptions/handlers.pyKeyedItemHookprimitive rather than adding a new type-keyed hook (per review); it stores(exc_type, handler)entries keyed onexc_type(duplicate →ValueError), and addsKeyedItemHook.iter_values()soassemble_appcan iterate themassemble_app()via_register_exception_handlersinsparkth/main.py, inheriting Starlette's MRO-correct dispatch throughadd_exception_handlergit mvsparkth/lib/exceptions.py→sparkth/lib/exceptions/__init__.py, preserving the existingHttpError/AuthenticationError/LMSRequestErrorsurface, and flag that legacystatus_code-carrying family as pre-standard so it isn't copied as the current patternassemble_app()performs the wiringCLAUDE.mdand the plugin authoring surface insparkth/core/plugins/PLUGIN_GUIDE.mdHow to Test
make mypy— passes (mypy --strict).make lint.backend && make lint.format.backend check=1— both clean.make test.backend.pytest— full suite passes. New coverage lives intests/core/exceptions/test_handlers.pyandtests/lib/test_hooks.py.register_exception_handler(SomeError, 409)), raiseSomeErrorfrom a route, and confirm the response is409with body{"detail": "<message>"}; a handler registered on a base exception also catches its subclasses.Notes
No migration, no breaking change, no new env var, no dependency bump. The registry ships empty — this PR delivers only the capability. Migrating existing endpoints (e.g.
whitelist, role-management routes) to the mechanism is intentionally out of scope and tracked in separate per-module issues. Fully unifying the remaining keyed-registry hook primitives (KeyedClassHook+KeyedItemHook) is tracked in #522.This description was written with the assistance of an LLM (Claude).
🤖 Generated with Claude Code