feat(rmi-backend,audit): AppError hierarchy in app/core/errors.py (P1.10)
Some checks failed
CI / build (push) Failing after 2s
Some checks failed
CI / build (push) Failing after 2s
Phase 1 of AUDIT-2026-Q3.md item P1.10.
Codebase had 2,532 except Exception: blocks and zero AppError class.
Bare exceptions made debugging in production impossible.
This change adds a strict typed hierarchy on top of the existing
register_error_handlers() (which app/lifespan.py imports):
AppError (base, status 500, code internal_error)
AuthError 401 auth.unauthorized
RateLimitError 429 ratelimit.exceeded
ValidationError 400 validation.failed (RMI-level, not pydantic)
NotFoundError 404 not_found
UpstreamError 502 upstream.failed
Each carries status_code (HTTP), code (machine-readable), and a
context dict that handlers forward to Sentry without code changes.
register_error_handlers() also now installs an AppError handler that
returns exc.to_dict() with a request_id, in addition to the existing
StarletteHTTPException / ValueError / Exception handlers.
Refs AUDIT-2026-Q3.md P1.10.
This commit is contained in:
parent
da2696a264
commit
92a01ffba0
1 changed files with 119 additions and 5 deletions
|
|
@ -1,19 +1,133 @@
|
||||||
"""RMI Backend - Global error handlers with structured responses.
|
"""RMI Backend - typed error hierarchy + global error handlers.
|
||||||
|
|
||||||
Registers FastAPI exception handlers that return consistent JSON error responses
|
Phase 1 of AUDIT-2026-Q3.md item P1.10.
|
||||||
with request IDs, tracebacks (dev only), and error codes.
|
|
||||||
|
The codebase had 2,532 ``except Exception:`` blocks and zero AppError class.
|
||||||
|
This module provides a strict typed hierarchy that handlers, routers, and
|
||||||
|
services should use instead of bare exception catches.
|
||||||
|
|
||||||
|
Two responsibilities coexist here:
|
||||||
|
1. The ``AppError`` hierarchy (this file's top).
|
||||||
|
2. ``register_error_handlers`` — used by ``app/lifespan.py`` to install
|
||||||
|
StarletteHTTPException / ValueError / Exception handlers on the FastAPI
|
||||||
|
app. Per-class handlers from ``app/error_handlers.py`` (404, AppError,
|
||||||
|
validation, 500) coexist with these and the more specific class wins.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import ( # noqa: TC002 - runtime use for FastAPI decorator + inspect.signature
|
||||||
|
FastAPI,
|
||||||
|
Request,
|
||||||
|
)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
|
# ── AppError hierarchy ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
"""Base class for all RMI application errors.
|
||||||
|
|
||||||
|
Subclasses set:
|
||||||
|
- ``status_code``: HTTP status to return (default 500)
|
||||||
|
- ``code``: short machine-readable string (e.g. "auth.invalid_token")
|
||||||
|
- ``message``: human-readable description
|
||||||
|
|
||||||
|
Carries an optional ``context`` dict that the global handler forwards to
|
||||||
|
Sentry without code changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status_code: int = 500
|
||||||
|
code: str = "internal_error"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "",
|
||||||
|
*,
|
||||||
|
code: str | None = None,
|
||||||
|
status_code: int | None = None,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
if code is not None:
|
||||||
|
self.code = code
|
||||||
|
if status_code is not None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self.context: dict[str, Any] = context or {}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": self.code,
|
||||||
|
"message": str(self),
|
||||||
|
"context": self.context,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(AppError):
|
||||||
|
"""401 — authentication or authorization failure."""
|
||||||
|
|
||||||
|
status_code = 401
|
||||||
|
code = "auth.unauthorized"
|
||||||
|
|
||||||
|
def __init__(self, message: str = "unauthorized", **kwargs: Any) -> None:
|
||||||
|
super().__init__(message, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(AppError):
|
||||||
|
"""429 — too many requests."""
|
||||||
|
|
||||||
|
status_code = 429
|
||||||
|
code = "ratelimit.exceeded"
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(AppError):
|
||||||
|
"""400 — request body or params failed validation.
|
||||||
|
|
||||||
|
Note: this is RMI's typed app-level ValidationError. Pydantic
|
||||||
|
ValidationError lives in pydantic and is handled separately by
|
||||||
|
app.error_handlers._register_validation. Importing pydantic's
|
||||||
|
ValidationError under a different alias in services is fine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status_code = 400
|
||||||
|
code = "validation.failed"
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(AppError):
|
||||||
|
"""404 — resource does not exist."""
|
||||||
|
|
||||||
|
status_code = 404
|
||||||
|
code = "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamError(AppError):
|
||||||
|
"""502 — an external service we depend on failed."""
|
||||||
|
|
||||||
|
status_code = 502
|
||||||
|
code = "upstream.failed"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Handler registration (used by app/lifespan.py) ──────────────────
|
||||||
|
|
||||||
|
|
||||||
def register_error_handlers(app: FastAPI, debug: bool = False) -> None:
|
def register_error_handlers(app: FastAPI, debug: bool = False) -> None:
|
||||||
"""Register global exception handlers on the FastAPI app."""
|
"""Register global exception handlers on the FastAPI app.
|
||||||
|
|
||||||
|
Per-class AppError handler is registered first so the more-specific
|
||||||
|
handler fires before the generic Exception handler below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||||
|
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||||
|
body = exc.to_dict()
|
||||||
|
body["request_id"] = request_id
|
||||||
|
return JSONResponse(status_code=exc.status_code, content=body)
|
||||||
|
|
||||||
@app.exception_handler(StarletteHTTPException)
|
@app.exception_handler(StarletteHTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue