"""FastAPI exception handlers producing the unified envelope (架构文档 §9.2). No handler is allowed to leak a stack trace to the client (FR-SYS-02). """ from __future__ import annotations from typing import Any from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException from packages.shared.errors import ERROR_META, AppError, ErrorCode from packages.shared.logging import get_logger from packages.shared.response import fail logger = get_logger(__name__) _HTTP_STATUS_TO_CODE: dict[int, ErrorCode] = { 400: ErrorCode.BAD_REQUEST, 401: ErrorCode.UNAUTHENTICATED, 403: ErrorCode.FORBIDDEN, 404: ErrorCode.NOT_FOUND, 405: ErrorCode.BAD_REQUEST, 409: ErrorCode.STATE_CONFLICT, 422: ErrorCode.VALIDATION_FAILED, 429: ErrorCode.RATE_LIMITED, 501: ErrorCode.NOT_IMPLEMENTED, 502: ErrorCode.UPSTREAM_ERROR, 503: ErrorCode.UPSTREAM_ERROR, } def _json(status: int, body: dict[str, Any]) -> JSONResponse: return JSONResponse(status_code=status, content=body) def install_exception_handlers(app: FastAPI) -> None: @app.exception_handler(AppError) async def _app_error(_request: Request, exc: AppError) -> JSONResponse: logger.warning("app_error", code=int(exc.code), error_message=exc.message) return _json( exc.http_status, fail(exc.code, message=exc.message, hint=exc.hint, data=exc.data or None), ) @app.exception_handler(RequestValidationError) async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse: details = [ {"field": ".".join(str(p) for p in err.get("loc", ())), "reason": err.get("msg", "")} for err in exc.errors() ] return _json( 422, fail(ErrorCode.VALIDATION_FAILED, data={"errors": details}), ) @app.exception_handler(StarletteHTTPException) async def _http_error(_request: Request, exc: StarletteHTTPException) -> JSONResponse: code = _HTTP_STATUS_TO_CODE.get(exc.status_code, ErrorCode.INTERNAL_ERROR) message = exc.detail if isinstance(exc.detail, str) else ERROR_META[code].message return _json(exc.status_code, fail(code, message=message)) @app.exception_handler(Exception) async def _unhandled(_request: Request, exc: Exception) -> JSONResponse: logger.exception("unhandled_error", error_type=type(exc).__name__) return _json(500, fail(ErrorCode.INTERNAL_ERROR))