feat(rmi-backend,audit): AppError hierarchy in app/core/errors.py (P1.10)
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:
opencode 2026-07-06 17:56:36 +02:00
parent da2696a264
commit 92a01ffba0

View file

@ -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):