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.
167 lines
5.3 KiB
Python
167 lines
5.3 KiB
Python
"""RMI Backend - typed error hierarchy + global error handlers.
|
|
|
|
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 ( # 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.
|
|
|
|
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):
|
|
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={
|
|
"error": exc.detail,
|
|
"code": exc.status_code,
|
|
"request_id": request_id,
|
|
},
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_exception_handler(request: Request, exc: Exception):
|
|
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
|
response = {
|
|
"error": "Internal server error",
|
|
"code": 500,
|
|
"request_id": request_id,
|
|
}
|
|
if debug:
|
|
response["traceback"] = traceback.format_exc().split("\n")
|
|
response["error"] = str(exc)
|
|
return JSONResponse(status_code=500, content=response)
|
|
|
|
@app.exception_handler(ValueError)
|
|
async def value_error_handler(request: Request, exc: ValueError):
|
|
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": str(exc),
|
|
"code": 400,
|
|
"request_id": request_id,
|
|
},
|
|
)
|