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
|
||||
with request IDs, tracebacks (dev only), and error codes.
|
||||
Phase 1 of AUDIT-2026-Q3.md item P1.10.
|
||||
|
||||
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 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 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:
|
||||
"""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)
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue