Files
hunjing/backend/app/core/errors.py
T
2026-08-21 16:00:13 +08:00

124 lines
4.5 KiB
Python

"""统一错误响应与异常处理。"""
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__},
)
)