173 lines
7.0 KiB
Python
173 lines
7.0 KiB
Python
"""统一异常 + 全局异常处理(单一入口)
|
|
|
|
设计原则(看一眼就懂):
|
|
- 每个异常类把 code / http_status 写死,调用处不用记码。
|
|
- 抛的时候传一句具体原因(detail);不传就用类别名兜底。
|
|
例:raise BizError("不支持的商品") → msg = "业务异常[不支持的商品]"
|
|
raise BizError() → msg = "业务异常"
|
|
- 所有 handler(自定义异常 / 参数校验 / HTTP / 断言 / 未知兜底)都注册在本文件,
|
|
main.py 只调用 register_exception_handlers(app)。
|
|
|
|
约定:自定义异常一律 HTTP 500,真实含义靠 body 里的 code 区分(前端拦截器读 code)。
|
|
|
|
| 异常类 | code | HTTP | 类别名 | 典型场景 |
|
|
|-------------|------|------|-----------|--------------------------------------|
|
|
| ParamError | 4000 | 500 | 参数异常 | 入参缺失/非法、模型档位不支持 |
|
|
| BizError | 4200 | 500 | 业务异常 | 资源不存在、限频、验证码、越权、会员校验 |
|
|
| CreditError | 4300 | 500 | 扣费异常 | 余额不足、扣费失败(前端引导充值) |
|
|
| SysError | 5000 | 500 | 系统异常 | 已知的内部错误 / 上游模型失败(主动抛) |
|
|
| (未捕获) | 5000 | 500 | 系统异常 | 没被建模的异常,兜底接住 |
|
|
"""
|
|
|
|
import traceback
|
|
|
|
from fastapi import Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from app.config import settings
|
|
from app.core.logger import log
|
|
from app.core.schemas.responses import StandardResponse
|
|
|
|
|
|
# ==================== 异常定义 ====================
|
|
|
|
class AppError(Exception):
|
|
"""自定义异常基类(不直接抛,统一捕获时用它兜底)
|
|
|
|
子类只需覆盖 code / label 两个类属性(http_status 一律 500)。
|
|
"""
|
|
|
|
code: int = 5000 # 业务错误码(固定)
|
|
http_status: int = 500 # 自定义异常一律 HTTP 500,真实含义靠 code 区分
|
|
label: str = "系统异常" # 类别名,detail 为空时作默认描述
|
|
|
|
def __init__(self, detail: str = ""):
|
|
# 传了原因 → "业务异常[不支持的商品]";没传 → "业务异常"
|
|
self.msg = f"{self.label}[{detail}]" if detail else self.label
|
|
super().__init__(self.msg)
|
|
|
|
def to_dict(self) -> dict:
|
|
"""HTTP 统一响应体字段"""
|
|
return {"code": self.code, "msg": self.msg}
|
|
|
|
def to_event(self) -> dict:
|
|
"""SSE error 事件数据体(前端流式解析用)"""
|
|
return {"code": self.code, "message": self.msg}
|
|
|
|
|
|
class ParamError(AppError):
|
|
"""参数异常:入参缺失 / 非法 / 不在允许范围"""
|
|
code = 4000
|
|
label = "参数异常"
|
|
|
|
|
|
class BizError(AppError):
|
|
"""业务异常:可预期的业务拒绝(资源不存在 / 限频 / 验证码 / 越权 / 会员校验…)"""
|
|
code = 4200
|
|
label = "业务异常"
|
|
|
|
|
|
class CreditError(AppError):
|
|
"""扣费异常:余额不足 / 扣费失败等积分相关问题,前端引导充值"""
|
|
code = 4300
|
|
label = "扣费异常"
|
|
|
|
|
|
class AssertError(AppError):
|
|
"""断言异常:业务前置条件不满足"""
|
|
code = 4100
|
|
label = "断言异常"
|
|
|
|
|
|
class SysError(AppError):
|
|
"""系统异常:已知的内部错误、上游模型调用失败等(主动抛)"""
|
|
code = 5000
|
|
label = "系统异常"
|
|
|
|
@classmethod
|
|
def from_exc(cls, exc: Exception) -> "SysError":
|
|
"""从任意异常包装成系统异常(流式兜底用)"""
|
|
return cls(f"{type(exc).__name__}: {exc}")
|
|
|
|
|
|
# ==================== 异常处理器 ====================
|
|
|
|
# HTTP 状态码 → 友好提示(框架抛出的 HTTPException 用)
|
|
_FRIENDLY_MESSAGES = {
|
|
400: "请求参数错误",
|
|
401: "未经授权,请登录",
|
|
403: "权限不足,禁止访问",
|
|
404: "请求的资源不存在",
|
|
405: "请求方法不允许",
|
|
422: "数据验证失败",
|
|
429: "请求过于频繁",
|
|
500: "服务器内部错误",
|
|
}
|
|
|
|
|
|
def _get_uuid(request: Request) -> str | None:
|
|
"""从请求上下文中获取请求唯一标识"""
|
|
return getattr(request.state, "uuid", None)
|
|
|
|
|
|
def _resp(code: int, msg: str, http_status: int, uuid: str | None, data=None) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=http_status,
|
|
content=StandardResponse.fail(msg=msg, code=code, data=data, uuid=uuid).model_dump(),
|
|
)
|
|
|
|
|
|
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
|
"""统一处理所有自定义异常(参数 / 业务 / 扣费 / 系统)
|
|
|
|
只在基类 AppError 上注册,Starlette 按继承链匹配,自动覆盖全部子类。
|
|
"""
|
|
uuid = _get_uuid(request)
|
|
# 系统异常(5xxx)记 error,其余可预期拒绝记 warning
|
|
level = log.error if exc.code >= 5000 else log.warning
|
|
level(f"{type(exc).__name__} -- uuid: {uuid} | code: {exc.code} | msg: {exc.msg}")
|
|
return _resp(exc.code, exc.msg, exc.http_status, uuid)
|
|
|
|
|
|
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
"""处理框架 / 手动抛出的 HTTPException"""
|
|
uuid = _get_uuid(request)
|
|
log.error(f"HTTPException -- uuid: {uuid} | status: {exc.status_code} | detail: {exc.detail}")
|
|
message = _FRIENDLY_MESSAGES.get(exc.status_code, str(exc.detail))
|
|
return _resp(exc.status_code, message, exc.status_code, uuid)
|
|
|
|
|
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
"""处理 Pydantic 参数校验异常"""
|
|
uuid = _get_uuid(request)
|
|
errors = exc.errors()
|
|
log.error(f"ValidationError -- uuid: {uuid} | errors: {errors}")
|
|
return _resp(ParamError.code, "数据验证失败", 422, uuid, data=errors)
|
|
|
|
|
|
async def assertion_error_handler(request: Request, exc: AssertionError) -> JSONResponse:
|
|
"""处理 assert 断言异常"""
|
|
uuid = _get_uuid(request)
|
|
log.error(f"AssertionError -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}")
|
|
return _resp(SysError.code, f"断言错误: {exc}", 500, uuid)
|
|
|
|
|
|
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
"""兜底:所有未捕获异常统一按系统异常返回"""
|
|
uuid = _get_uuid(request)
|
|
log.error(f"Unhandled Exception -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}")
|
|
# 开发环境暴露细节,生产环境只给通用提示
|
|
msg = f"{type(exc).__name__}: {exc}" if settings.env == "dev" else SysError.label
|
|
return _resp(SysError.code, msg, 500, uuid)
|
|
|
|
|
|
def register_exception_handlers(app) -> None:
|
|
"""将所有异常处理器挂载到 FastAPI 应用(main.py 只调这一个)"""
|
|
app.add_exception_handler(AppError, app_error_handler)
|
|
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
|
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
|
app.add_exception_handler(AssertionError, assertion_error_handler)
|
|
app.add_exception_handler(Exception, global_exception_handler)
|