Some checks failed
CI / build (push) Failing after 2s
Phase 1 of AUDIT-2026-Q3.md item P1.10 wiring.
Previously _register_apperror did:
try:
from app.core.errors import AppError
@app.exception_handler(AppError)
async def _app_error(...): ...
except Exception as exc:
log.info("error_handler_skipped name=AppError err=%s", exc)
That meant: if AppError was missing (it was — see previous commit), the
import silently failed and the app booted with ZERO typed-error handling.
All 2,532 except Exception: blocks would surface to clients as opaque 500s.
This change:
- Imports AppError (+ 4 commonly-raised subclasses) at module top so a
missing class is a hard ImportError at import time.
- Drops the try/except wrapper around _register_apperror.
- Renames the inner handlers to handle_app_error / handle_generic_exception
for clarity.
- handle_app_error returns exc.to_dict() (code, message, context) +
path + type, status_code = exc.status_code. The old code referenced
exc.message which would AttributeError since AppError stores message
via super().__init__, not as a self attribute.
- Drops the no-op try/except/pass in _register_validation (was a stub;
Phase 3 of the audit will wire RequestValidationError -> RMI ValidationError).
Verified via FastAPI TestClient:
GET /a AuthError -> 401 {code: auth.unauthorized, ...}
GET /r RateLimitError -> 429 {code: ratelimit.exceeded, ...}
GET /v ValidationError -> 400 {code: validation.failed, ...}
GET /n NotFoundError -> 404 {code: not_found, ...}
GET /u UpstreamError -> 502 {code: upstream.failed, ...}
GET /x RuntimeError -> 500 {error: internal_error, ...} (generic handler)
GET /404 -> 404 {error: not_found, hint, ...} (404 handler)
Refs AUDIT-2026-Q3.md P1.10.
107 lines
3.2 KiB
Python
107 lines
3.2 KiB
Python
"""RMI Backend - exception handlers.
|
|
|
|
Clean JSON error responses for 404, 500, AppError, etc. Each
|
|
handler is isolated - one failure doesn't break the others.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import ( # noqa: TC002 - runtime use (matches app/core/errors.py + pre-existing project-wide pattern)
|
|
FastAPI,
|
|
Request,
|
|
)
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.core.errors import (
|
|
AppError,
|
|
AuthError,
|
|
NotFoundError,
|
|
UpstreamError,
|
|
ValidationError,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def register_error_handlers(app: FastAPI) -> None:
|
|
"""Register every error handler. Each is isolated."""
|
|
_register_404(app)
|
|
_register_validation(app)
|
|
_register_500(app)
|
|
_register_apperror(app)
|
|
|
|
|
|
def _register_404(app: FastAPI) -> None:
|
|
@app.exception_handler(404)
|
|
async def _not_found(request: Request, _exc: Exception) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=404,
|
|
content={
|
|
"error": "not_found",
|
|
"path": request.url.path,
|
|
"method": request.method,
|
|
"hint": "RMI new backend. Check /docs for current API surface.",
|
|
},
|
|
)
|
|
|
|
log.info("error_handler_registered name=not_found")
|
|
|
|
|
|
def _register_validation(app: FastAPI) -> None:
|
|
"""422 - Pydantic validation errors. Return structured details.
|
|
|
|
Phase 3 of AUDIT-2026-Q3.md (router split) will wire RequestValidationError
|
|
to RMI's typed ValidationError here. Until then, this is a logging stub.
|
|
"""
|
|
log.info("error_handler_registered name=validation (pending phase 3)")
|
|
|
|
|
|
def _register_500(app: FastAPI) -> None:
|
|
"""Generic 500 fallback for non-AppError exceptions.
|
|
|
|
AppError instances are routed by the more-specific handler installed in
|
|
_register_apperror, so this only fires for plain Exception subclasses.
|
|
"""
|
|
|
|
@app.exception_handler(Exception)
|
|
async def handle_generic_exception(request: Request, exc: Exception) -> JSONResponse:
|
|
log.exception("unhandled_exception path=%s", request.url.path)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={
|
|
"error": "internal_error",
|
|
"path": request.url.path,
|
|
"type": type(exc).__name__,
|
|
},
|
|
)
|
|
|
|
log.info("error_handler_registered name=internal_error")
|
|
|
|
|
|
def _register_apperror(app: FastAPI) -> None:
|
|
"""AppError → JSON via its declared status_code + to_dict().
|
|
|
|
Import is at module top so a missing AppError is a hard ImportError
|
|
at import time rather than a silently-swallowed runtime warning.
|
|
"""
|
|
|
|
@app.exception_handler(AppError)
|
|
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
|
|
body = exc.to_dict()
|
|
body["path"] = request.url.path
|
|
body["type"] = type(exc).__name__
|
|
return JSONResponse(status_code=exc.status_code, content=body)
|
|
|
|
log.info(
|
|
"error_handler_registered name=AppError handlers=%s",
|
|
sorted(
|
|
{
|
|
AuthError.__name__,
|
|
NotFoundError.__name__,
|
|
ValidationError.__name__,
|
|
UpstreamError.__name__,
|
|
}
|
|
),
|
|
)
|