第12课:FastAPI|全局异常捕获|内置异常与自定义业务异常封装

在这里插入图片描述


1. 课前导读

本节课学习目标

在前11课中,我们已经能够开发功能完备的API接口,并通过中间件实现了日志、跨域等全局功能。然而,在真实的生产环境中,错误和异常是不可避免的——客户端请求参数错误、数据库连接失败、业务逻辑违反约束等。如果不对这些异常进行统一处理,客户端可能收到晦涩的堆栈信息(500错误)或者不规范的错误响应,严重影响用户体验和调试效率。

FastAPI提供了全局异常处理器机制,允许你捕获特定类型或所有异常,并返回自定义的响应格式。学完本节课,你将:

  • 理解FastAPI的异常处理体系:HTTPException、RequestValidationError、未处理异常的区别
  • 掌握全局异常捕获:使用@app.exception_handler装饰器统一处理异常
  • 自定义业务异常类:继承HTTPException或Exception,封装业务错误码和消息
  • 实现统一的错误响应格式:结合第6课的统一返回格式,让错误响应也保持规范
  • 处理Pydantic校验错误:自定义422响应,让参数校验错误更友好
  • 区分开发与生产环境的异常详情:避免在生产环境泄露敏感信息

前置知识

  • 已完成第6课(统一返回格式)
  • 熟悉Python异常处理机制(try/except)
  • 了解HTTP状态码及其含义

学完能掌握什么

学完本节课后,你将具备以下能力:

  1. 统一处理各种异常:无论是框架抛出的HTTPException,还是Pydantic的校验错误,都能返回规范的JSON
  2. 定义业务异常:例如UserNotFoundExceptionInsufficientBalanceError,提升代码可读性
  3. 在路径函数中优雅抛出异常:不再手动返回错误响应,而是raise异常
  4. 生产环境隐藏错误细节:防止泄露文件路径、数据库结构等敏感信息
  5. 为API客户端提供一致的错误体验:始终返回{code, message, data}格式

适用人群

  • 希望API错误响应规范化、统一化的开发者
  • 需要区分业务错误和系统错误的架构师
  • 正在构建微服务,需要统一错误码体系的团队
  • 对FastAPI异常处理机制想要深入理解的学员

2. 核心理论讲解

2.1 FastAPI的异常处理层次

FastAPI中异常的传播和处理分为几个层次:

  1. 路径操作函数内未捕获的异常 → 向上抛出
  2. 依赖函数中的异常 → 同样向上抛出
  3. 中间件中的异常 → 可以在中间件中捕获,否则继续向上
  4. 全局异常处理器 → 捕获特定类型异常,返回自定义响应
  5. 默认异常处理器 → 如果没有任何处理器匹配,返回默认错误(HTML或JSON)

默认行为

  • HTTPException:返回{"detail": "错误信息"},状态码由异常指定。
  • RequestValidationError(Pydantic校验错误):返回包含字段级错误详情的422 JSON。
  • 其他未预期异常(如AttributeError):返回500 Internal Server Error,并在控制台打印堆栈。

2.2 HTTPException详解

HTTPException是FastAPI最常用的异常类,用于返回HTTP错误响应。

from fastapi import HTTPException

raise HTTPException(status_code=404, detail="Item not found")
# 可选参数: headers={"X-Error": "..."}

特点

  • 支持任意4xx/5xx状态码。
  • detail可以是字符串或可序列化的JSON。
  • 可以被全局异常处理器捕获并自定义。

2.3 RequestValidationError与Pydantic校验

当请求参数(查询参数、路径参数、请求体)无法通过Pydantic校验时,FastAPI会抛出RequestValidationError。其结构包含错误位置、错误类型等信息。

默认响应体示例:

{
  "detail": [
    {
      "loc": ["body", "age"],
      "msg": "Input should be a valid integer",
      "type": "int_parsing"
    }
  ]
}

2.4 自定义业务异常的设计模式

在大型项目中,通常不会直接使用HTTPException,而是定义自己的异常类,以便:

  • 统一管理错误码(如1001表示用户不存在)
  • 附加更多元数据(如出错字段名称)
  • 便于全局处理器根据异常类型做不同处理

推荐做法:定义一个基础业务异常类,继承Exception,包含codemessagestatus_code属性。然后在全局处理器中将它们转换为HTTP响应。

2.5 与Flask/Django异常处理对比

框架内置异常全局处理方式自定义异常支持
FastAPIHTTPExceptionRequestValidationError@app.exception_handler非常灵活
Flaskabort(status_code)@app.errorhandler支持自定义异常
DjangoHttp404PermissionDenied中间件或handler404支持

FastAPI的优势在于异步支持和与Pydantic的无缝集成。


3. 环境搭建 & 实操准备

3.1 复用项目环境

继续使用第11课的项目。确保虚拟环境已激活。

cd ~/fastapi_course/lesson03_first_project
source venv/bin/activate

3.2 安装依赖

无需额外依赖。

3.3 新增代码文件

创建app/api/v1/exceptions_demo.py,并在main.py中注册。

touch app/api/v1/exceptions_demo.py

main.py中注册:

from app.api.v1 import exceptions_demo
app.include_router(exceptions_demo.router, prefix="/api/v1/ex", tags=["异常处理演示"])

同时,我们在app/main.py中添加全局异常处理器(见实战部分)。


4. 手把手代码实战

4.1 默认HTTPException的使用

# app/api/v1/exceptions_demo.py
from fastapi import APIRouter, HTTPException, status

router = APIRouter()

@router.get("/items/{item_id}")
async def get_item(item_id: int):
    if item_id <= 0:
        raise HTTPException(status_code=400, detail="item_id必须为正整数")
    if item_id > 100:
        raise HTTPException(status_code=404, detail="商品不存在")
    return {"item_id": item_id, "name": f"商品{item_id}"}

测试

curl http://localhost:8000/api/v1/ex/items/0
# 返回: {"detail":"item_id必须为正整数"} 状态码400

4.2 自定义异常类与处理器

首先,在app/exceptions.py中定义业务异常类:

touch app/exceptions.py
# app/exceptions.py
class BusinessException(Exception):
    """业务异常基类"""
    def __init__(self, code: int, message: str, status_code: int = 400):
        self.code = code
        self.message = message
        self.status_code = status_code
        super().__init__(message)

class UserNotFoundException(BusinessException):
    def __init__(self, user_id: int):
        super().__init__(code=1001, message=f"用户 {user_id} 不存在", status_code=404)

class InsufficientBalanceException(BusinessException):
    def __init__(self, required: float, current: float):
        super().__init__(
            code=1002,
            message=f"余额不足,需要 {required},当前 {current}",
            status_code=400
        )

然后在app/main.py中添加全局异常处理器:

from app.exceptions import BusinessException
from fastapi import Request
from fastapi.responses import JSONResponse
from app.schemas.response import ResponseModel  # 第6课定义

@app.exception_handler(BusinessException)
async def business_exception_handler(request: Request, exc: BusinessException):
    return JSONResponse(
        status_code=exc.status_code,
        content=ResponseModel.error(code=exc.code, message=exc.message).model_dump()
    )

在路由中使用:

from app.exceptions import UserNotFoundException, InsufficientBalanceException

@router.get("/user/{user_id}")
async def get_user(user_id: int):
    if user_id != 1:
        raise UserNotFoundException(user_id)
    return {"id": 1, "name": "Alice"}

@router.post("/withdraw")
async def withdraw(amount: float, user_id: int = 1):
    balance = 100.0
    if amount > balance:
        raise InsufficientBalanceException(required=amount, current=balance)
    return {"message": "提现成功", "new_balance": balance - amount}

测试

curl http://localhost:8000/api/v1/ex/user/999
# 响应: {"code":1001,"message":"用户 999 不存在","data":null} 状态码404

4.3 全局捕获所有未处理异常

为了防止泄露敏感信息,在生产环境中应统一捕获所有未预期异常,返回通用错误。

@app.exception_handler(Exception)
async def universal_exception_handler(request: Request, exc: Exception):
    # 记录完整的堆栈到日志
    import traceback
    logger.error(f"Unhandled exception: {traceback.format_exc()}")
    # 返回通用错误(不暴露细节)
    return JSONResponse(
        status_code=500,
        content=ResponseModel.error(code=500, message="服务器内部错误,请稍后再试").model_dump()
    )

注意:这个处理器会捕获所有未被其他处理器处理的异常,包括HTTPException(如果放在更后面)。为了不影响HTTPException的正常处理,需确保HTTPException的处理器先注册。顺序很重要:具体异常处理器应在通用异常处理器之前注册

4.4 自定义RequestValidationError处理器

Pydantic校验错误的默认格式对前端不够友好,可以统一转换成业务错误格式。

from fastapi.exceptions import RequestValidationError
from fastapi import Request
from fastapi.responses import JSONResponse

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    # 提取错误信息
    errors = []
    for error in exc.errors():
        field = ".".join(str(loc) for loc in error["loc"])
        msg = error["msg"]
        errors.append(f"{field}: {msg}")
    message = "; ".join(errors)
    return JSONResponse(
        status_code=422,
        content=ResponseModel.error(code=422, message=f"参数校验失败: {message}").model_dump()
    )

测试:访问一个参数错误的接口,例如:

@router.get("/validate")
async def validate_test(q: int = None):
    return {"q": q}

请求/validate?q=abc,原本返回Pydantic默认错误,现在返回统一格式。

4.5 HTTPException的统一格式化

默认的HTTPException返回{"detail": "..."},也可以统一改为业务格式。

from fastapi import HTTPException

@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content=ResponseModel.error(code=exc.status_code, message=exc.detail).model_dump()
    )

但注意:如果希望保留某些HTTPException的原有detail结构(如dict),需要特殊处理。

4.6 多个异常处理器的优先级

FastAPI按照继承关系注册顺序选择处理器:

  • 异常处理器会匹配异常类型及其子类。
  • 如果有多个匹配,选择最具体的那个(最深的继承链)。
  • 如果同样具体,按注册顺序靠前的优先(但通常避免依赖顺序)。

因此,推荐先注册具体异常,后注册通用异常。

4.7 在依赖和中间件中抛出异常

依赖函数和中间件中也可以抛出异常,会被全局处理器捕获。

def auth_dep(token: str = Header(...)):
    if token != "valid":
        raise HTTPException(401, "Invalid token")
    return {"user": "admin"}

@router.get("/secure")
async def secure_endpoint(auth=Depends(auth_dep)):
    return {"data": "secret"}

4.8 区分开发和生产环境的异常详情

可以通过环境变量控制是否在错误响应中返回详细堆栈。

from app.config import settings

@app.exception_handler(Exception)
async def universal_exception_handler(request: Request, exc: Exception):
    logger.error(exc, exc_info=True)
    if settings.debug:
        # 开发环境返回详细错误
        content = {
            "code": 500,
            "message": str(exc),
            "data": {"traceback": traceback.format_exc()}
        }
    else:
        content = ResponseModel.error(code=500, message="服务器内部错误").model_dump()
    return JSONResponse(status_code=500, content=content)

4.9 处理Starlette的异常(如NotFound)

Starlette自带一些异常(如HTTPException是其子类),无需额外处理。

4.10 实战:完整的异常处理体系整合

我们将第6课的统一返回格式、第10课的依赖注入、本课异常处理整合到同一个项目中。

app/main.py中集中配置所有异常处理器:

# 异常处理器注册顺序(具体 -> 通用)
app.exception_handler(BusinessException)(business_exception_handler)
app.exception_handler(RequestValidationError)(validation_exception_handler)
app.exception_handler(HTTPException)(http_exception_handler)
app.exception_handler(Exception)(universal_exception_handler)

并确保所有路由函数中抛出的是BusinessExceptionHTTPException

4.11 测试异常处理

编写一个测试路由,演示各种异常:

@router.get("/test-exceptions/{mode}")
async def test_exceptions(mode: str):
    if mode == "http":
        raise HTTPException(400, "HTTP异常")
    elif mode == "business":
        raise UserNotFoundException(123)
    elif mode == "validation":
        # 会触发RequestValidationError(通过参数)
        return {"ok": True}
    elif mode == "unhandled":
        raise ValueError("未知错误")
    return {"message": "ok"}

访问不同路径,观察响应格式是否符合预期。

4.12 自定义异常处理器的高级用法:根据请求头返回不同格式

有些API需要同时支持JSON和XML,可以根据Accept头返回不同格式。但一般保持JSON即可。


5. 重点知识点总结

异常处理核心语法速查表

功能代码
抛出HTTPExceptionraise HTTPException(status_code=404, detail="...")
定义业务异常class MyError(Exception): pass
注册异常处理器@app.exception_handler(MyError)
处理器函数签名async def handler(request: Request, exc: MyError): return JSONResponse(...)
捕获所有异常@app.exception_handler(Exception)
获取请求详情使用request对象,如request.url

易错点与避坑指南

  1. ❌ 异常处理器返回的JSONResponse未设置状态码
    状态码应与异常含义一致(如404),否则客户端可能误判。必须设置status_code参数。

  2. ❌ 在通用异常处理器中覆盖了HTTPException
    导致HTTPException也被转换为500错误。应确保HTTPException有专门的处理器,或通过判断类型区分。

  3. ❌ 在异常处理器中再次抛出异常
    会导致无限循环。处理器必须返回响应,不能raise

  4. ❌ 忘记在业务异常中设置status_code
    默认200会误导客户端。业务错误应使用4xx状态码。

  5. ❌ 在生产环境返回详细的堆栈信息
    可能泄露敏感信息。务必通过DEBUG开关控制。

  6. ❌ 对RequestValidationError的处理器未正确处理字段位置
    error["loc"]是一个列表,如["body", "age"],转为字符串时需用.连接。

最佳实践

  1. 统一使用业务异常类:避免到处raise HTTPException,便于集中管理错误码。
  2. 为每个业务错误定义有意义的错误码:如1001用户不存在,1002余额不足,便于前端做国际化。
  3. 在异常处理器中记录日志:特别是未预期异常,使用logger.error(exc_info=True)记录堆栈。
  4. 使用Pydantic模型规范错误响应体:如ErrorResponse(code, message, details)
  5. 为不同异常类型提供不同的错误格式:例如参数校验错误可以返回字段级错误详情。
  6. 在测试中覆盖异常路径:确保每个异常处理器被触发时响应符合预期。

6. 课后作业 & 思考题

实操练习题(必做)

  1. 基础练习:实现一个资源不存在异常

    • 定义ResourceNotFoundError继承BusinessException,默认状态码404,错误码1003
    • 在获取文章接口中,如果文章ID不存在则抛出该异常
    • 注册全局处理器,返回统一格式
  2. 进阶练习:参数校验错误详细化

    • 修改RequestValidationError的处理器,返回结构化的错误详情,例如:
      {"code":422,"message":"参数校验失败","data":{"errors":[{"field":"age","msg":"必须是整数"}]}}
      
    • 确保前端能清晰知道哪个字段出错
  3. 挑战练习:集成JWT异常处理

    • 模拟JWT过期或无效时抛出InvalidTokenError
    • 为这个异常添加处理器,返回401状态码和特定错误码
    • 并在依赖get_current_user中测试

理论思考题

  1. 为什么FastAPI默认的RequestValidationError响应格式是{"detail": [...]}?这种设计的优缺点是什么?
  2. 全局异常处理器和中间件中的异常处理有什么区别?什么时候用哪个?
  3. 如何确保自定义异常处理器能够同时处理同步和异步函数中抛出的异常?
  4. 业务错误码和HTTP状态码应该如何映射? 举例说明。

拓展学习方向

  • 错误码规范设计:学习大型互联网公司的错误码体系(如支付宝、微信支付)
  • OpenAPI错误响应文档:使用responses参数描述可能返回的错误
  • Sentry集成:将未捕获异常自动上报到Sentry错误追踪系统
  • GraphQL错误处理:对比RESTful错误处理的不同

7. 本节干货总结

核心考点(面试/自测)

  1. 如何全局捕获FastAPI中所有未处理异常?
    使用@app.exception_handler(Exception)装饰一个处理器函数。

  2. HTTPException和自定义业务异常的区别是什么?
    HTTPException是框架内置,用于快速返回HTTP错误;自定义异常可以封装业务错误码和额外上下文。

  3. 如何让Pydantic校验错误返回自定义格式?
    注册RequestValidationError的异常处理器,解析exc.errors()并返回自定义响应。

  4. 多个异常处理器的执行顺序如何确定?
    按照异常类型的继承关系(越具体优先级越高),若继承层次相同,按注册顺序(但通常避免依赖顺序)。

  5. 生产环境为什么不能返回详细堆栈?
    防止泄露代码路径、数据库结构、内部逻辑等信息,可能导致安全漏洞。

实际工作应用场景

  • 场景1:用户认证失败
    抛出AuthenticationError,处理器返回401,错误码10001,前端跳转登录页。

  • 场景2:资源不存在
    抛出ResourceNotFoundError,返回404,错误码40401,前端显示“内容不存在”。

  • 场景3:参数校验失败
    默认422错误通过处理器转换为结构化字段错误,前端可高亮具体表单字段。

  • 场景4:第三方服务调用失败
    抛出ServiceUnavailableError,返回503,并记录详细日志,触发告警。

  • 场景5:数据库唯一约束冲突
    转换为DuplicateKeyError,返回409,提示用户“名称已存在”。


下节课预告

本节课我们建立了完善的异常处理体系,API在面对错误时也能优雅响应。但一个优秀的API不仅要有健壮的错误处理,还应该有清晰、易用的文档。FastAPI自动生成的Swagger UI已经非常强大,但我们可以进一步定制文档,添加描述、示例、分组、权限控制等。

下节课(第13课),我们将学习:

  • Swagger/ReDoc文档定制:修改文档标题、描述、版本
  • 添加接口分组和标签:让文档更清晰
  • 定义响应示例和请求示例:提高文档可用性
  • 隐藏内部接口或禁用文档:生产环境安全控制
  • 文档权限配置:需要登录才能访问文档页面

让API文档成为项目最好的说明书!


🔗《20节课 FastAPI 从入门到精通》系列课程导航

去订阅

🌟 感谢您耐心阅读到这里!
💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Thomas.Sir

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值