"""统一错误响应与异常处理。""" from contextvars import ContextVar from typing import Any from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException from app.core.ids import new_trace_id from app.core.logging import log_with_context from app.core.security import redact_value from app.modules.safety.exceptions import SafetyError trace_id_var: ContextVar[str | None] = ContextVar("trace_id", default=None) ERROR_HTTP_STATUS: dict[str, int] = { "invalid_request": status.HTTP_400_BAD_REQUEST, "unauthorized": status.HTTP_401_UNAUTHORIZED, "permission_denied": status.HTTP_403_FORBIDDEN, "requires_confirmation": status.HTTP_403_FORBIDDEN, "not_found": status.HTTP_404_NOT_FOUND, "conflict": status.HTTP_409_CONFLICT, "payload_too_large": status.HTTP_413_CONTENT_TOO_LARGE, "unsupported_media_type": status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "validation_failed": status.HTTP_422_UNPROCESSABLE_CONTENT, "context_overflow": status.HTTP_400_BAD_REQUEST, "run_timeout": status.HTTP_504_GATEWAY_TIMEOUT, "model_error": status.HTTP_502_BAD_GATEWAY, "model_rate_limited": status.HTTP_429_TOO_MANY_REQUESTS, "model_timeout": status.HTTP_504_GATEWAY_TIMEOUT, "model_config_error": status.HTTP_500_INTERNAL_SERVER_ERROR, "model_unavailable": status.HTTP_503_SERVICE_UNAVAILABLE, "model_provider_key_missing": status.HTTP_500_INTERNAL_SERVER_ERROR, "tool_error": status.HTTP_502_BAD_GATEWAY, "safety_rejected": status.HTTP_403_FORBIDDEN, "rate_limited": status.HTTP_429_TOO_MANY_REQUESTS, "internal_error": status.HTTP_500_INTERNAL_SERVER_ERROR, } class AppError(Exception): def __init__( self, code: str, message: str, *, status_code: int | None = None, details: dict[str, Any] | None = None, ) -> None: self.code = code self.message = message self.status_code = status_code or ERROR_HTTP_STATUS.get(code, status.HTTP_400_BAD_REQUEST) self.details = details or {} def current_trace_id() -> str: trace_id = trace_id_var.get() if trace_id is None: trace_id = new_trace_id() trace_id_var.set(trace_id) return trace_id def error_response(error: AppError) -> JSONResponse: return JSONResponse( status_code=error.status_code, content={ "error": { "code": error.code, "message": error.message, "trace_id": current_trace_id(), "details": redact_value(error.details), } }, ) def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(AppError) async def handle_app_error(_request: Request, exc: AppError) -> JSONResponse: return error_response(exc) @app.exception_handler(SafetyError) async def handle_safety_error(_request: Request, exc: SafetyError) -> JSONResponse: # 司命异常自带契约错误码(如 permission_denied → 403),按错误码映射 HTTP 状态。 return error_response(AppError(exc.code, exc.message)) @app.exception_handler(RequestValidationError) async def handle_validation_error( _request: Request, exc: RequestValidationError ) -> JSONResponse: return error_response( AppError( "validation_failed", "请求字段校验失败。", status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, details={"errors": exc.errors()}, ) ) @app.exception_handler(StarletteHTTPException) async def handle_http_error(_request: Request, exc: StarletteHTTPException) -> JSONResponse: code = "not_found" if exc.status_code == status.HTTP_404_NOT_FOUND else "invalid_request" return error_response(AppError(code, str(exc.detail), status_code=exc.status_code)) @app.exception_handler(Exception) async def handle_unexpected_error(_request: Request, exc: Exception) -> JSONResponse: log_with_context( "未处理异常", level="ERROR", trace_id=current_trace_id(), error_type=exc.__class__.__name__, ) return error_response( AppError( "internal_error", "服务端内部错误。", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, details={"type": exc.__class__.__name__}, ) )