在 FastAPI 项目里,如果每个接口都写一堆 try...except,项目刚开始可能还能接受,接口一多就会变得很乱:
- 每个接口返回的错误格式不一致。
- controller、service、crud 的职责边界变模糊。
- 前端不好统一处理错误提示。
- 日志记录分散,线上问题不好排查。
- 系统异常可能直接暴露给前端,存在安全风险。
更推荐的方式是:controller / service / crud 只写正常业务流程,业务异常统一 raise,最后由 FastAPI 的全局异常处理器统一转换成响应。
本文会用一套比较贴近实际项目的写法,整理 FastAPI 全局异常处理的设计思路和代码模板。
一、本文适合什么场景
本文适合下面这些场景:
- FastAPI 项目想统一接口返回格式。
- 不想在每个接口里重复写
try...except。 - 希望业务异常、参数异常、系统异常分开处理。
- 希望 controller、service、crud 分层更清楚。
- 希望项目里有一套可以复用的异常处理模板。
示例不绑定具体 ORM。你用 SQLAlchemy、Tortoise ORM、SQLModel 或其他数据库工具,都可以按这个思路改造。
二、为什么需要全局异常处理
先看一种不太推荐的写法:
python
@router.post("/users")
async def create_user_api(form: UserCreate):
try:
user = await create_user(form)
return {
"success": True,
"code": 0,
"message": "创建成功",
"data": user,
}
except Exception as exc:
return JSONResponse(
status_code=500,
content={
"success": False,
"code": 50000,
"message": str(exc),
},
)
这段代码的问题是:
- controller 既处理请求,又处理异常响应,职责太重。
except Exception太宽泛,容易把业务异常和系统异常混在一起。str(exc)可能把数据库错误、文件路径、内部实现细节暴露给前端。- 每个接口都这样写,会产生大量重复代码。
更好的做法是:
python
@router.post("/users")
async def create_user_api(form: UserCreate):
user = await create_user(form)
return success_response(data=user, message="创建用户成功")
controller 只关心正常流程,异常交给全局异常处理器。
一句话总结:
正常流程写在业务代码里,异常响应统一交给 exception handler。
三、FastAPI 官方异常机制
FastAPI 本身已经提供了异常处理机制,主要有几个常用点:
| 机制 | 作用 |
|---|---|
HTTPException |
FastAPI 内置异常,适合返回 HTTP 错误 |
RequestValidationError |
请求参数校验失败时触发 |
@app.exception_handler(...) |
注册全局异常处理器 |
app.add_exception_handler(...) |
另一种集中注册异常处理器的方式 |
FastAPI 官方文档里也强调:HTTPException 是一个普通的 Python 异常,只是带了 HTTP 响应相关信息。你可以在接口函数、service、依赖函数、工具函数里 raise 它,请求会被中断,然后由异常处理器生成响应。
项目里如果需要统一响应格式,就可以通过 @app.exception_handler(...) 或 app.add_exception_handler(...) 覆盖默认异常响应。
四、异常分类
项目中不要把所有错误都当成一种异常。一般可以分成四类。
1. 业务异常
业务异常指的是:代码运行没有问题,但是业务规则不允许继续执行。
比如:
- 用户不存在
- 用户名已存在
- 密码错误
- 余额不足
- 库存不足
- 订单状态不允许取消
这种异常适合定义成自己的业务异常类,例如 BusinessException。
2. 参数异常
参数异常指的是请求参数不符合接口要求。
比如:
- 必填字段没传
- 字段类型错误
- 字符串长度不符合要求
- 枚举值不合法
- 路径参数类型错误
FastAPI 结合 Pydantic 做参数校验时,如果请求参数不合法,会触发 RequestValidationError。
3. 认证和权限异常
这类异常一般和登录、token、角色权限有关。
比如:
- 未登录
- token 过期
- token 无效
- 没有接口访问权限
- 没有资源操作权限
可以直接使用 HTTPException(status_code=401) 或 HTTPException(status_code=403),也可以根据项目习惯封装成业务异常。
4. 系统异常
系统异常是代码运行过程中出现的非预期错误。
比如:
- 数据库连接失败
- Redis 连接失败
- 第三方接口超时
- 文件读写失败
- 未捕获的 Python 异常
系统异常不应该把原始异常信息直接返回给前端。更合理的方式是:日志里记录真实异常,前端只收到统一的错误提示。
五、推荐项目结构
可以按下面这种结构组织代码:
python
app/
├── main.py
├── core/
│ ├── response.py
│ ├── exceptions.py
│ └── exception_handlers.py
├── api/
│ └── user.py
├── services/
│ └── user_service.py
├── crud/
│ └── user_crud.py
└── schemas/
└── user.py
核心文件说明:
| 文件 | 作用 |
|---|---|
response.py |
统一成功和失败响应格式 |
exceptions.py |
定义自定义业务异常 |
exception_handlers.py |
定义全局异常处理器 |
main.py |
注册异常处理器 |
services/ |
写业务流程,业务不成立时 raise |
crud/ |
写数据库操作,尽量保持简单 |
六、统一响应格式
为了让前端更好处理,建议接口统一返回类似结构:
python
{
"success": false,
"code": 40001,
"message": "用户名已存在",
"data": null
}
字段说明:
| 字段 | 含义 |
|---|---|
success |
业务是否成功 |
code |
业务状态码 |
message |
错误提示或成功提示 |
data |
业务数据 |
这里要注意区分两个概念:
status_code:HTTP 状态码,比如 200、400、401、403、404、422、500。code:业务状态码,比如 40001 表示用户名已存在。
不要把 HTTP 状态码和业务状态码混在一起。HTTP 状态码表示请求层面的结果,业务 code 表示项目内部约定的业务结果。
七、代码模板
下面给出一套可以直接放进项目里的基础模板。
1. 统一响应工具
python
# app/core/response.py
from typing import Any
def success_response(
data: Any = None,
message: str = "success",
code: int = 0,
) -> dict:
return {
"success": True,
"code": code,
"message": message,
"data": data,
}
def error_response(
message: str,
code: int,
data: Any = None,
) -> dict:
return {
"success": False,
"code": code,
"message": message,
"data": data,
}
2. 自定义业务异常
python
# app/core/exceptions.py
from http import HTTPStatus
class BusinessException(Exception):
def __init__(
self,
message: str,
code: int = 40000,
status_code: int = HTTPStatus.BAD_REQUEST,
):
self.message = message
self.code = code
self.status_code = status_code
这个异常类只保存异常信息,不负责返回 JSONResponse。
字段说明:
| 字段 | 作用 |
|---|---|
message |
返回给前端的错误提示 |
code |
业务错误码 |
status_code |
HTTP 状态码 |
3. 全局异常处理器
python
# app/core/exception_handlers.py
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.exceptions import BusinessException
from app.core.response import error_response
logger = logging.getLogger(__name__)
async def business_exception_handler(
request: Request,
exc: BusinessException,
) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=error_response(
code=exc.code,
message=exc.message,
),
)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
return JSONResponse(
status_code=422,
content=error_response(
code=42200,
message="请求参数校验失败",
data=exc.errors(),
),
)
async def http_exception_handler(
request: Request,
exc: StarletteHTTPException,
) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=error_response(
code=exc.status_code,
message=str(exc.detail),
),
)
async def unknown_exception_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
logger.exception("未处理的系统异常: %s %s", request.method, request.url)
return JSONResponse(
status_code=500,
content=error_response(
code=50000,
message="服务器内部错误",
),
)
def register_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(BusinessException, business_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
app.add_exception_handler(Exception, unknown_exception_handler)
这里用了 StarletteHTTPException,是因为 FastAPI 底层基于 Starlette。这样不仅能处理 FastAPI 主动抛出的 HTTPException,也能处理 Starlette 内部或相关扩展抛出的 HTTP 异常。
4. 在 main.py 中注册
python
# app/main.py
from fastapi import FastAPI
from app.core.exception_handlers import register_exception_handlers
app = FastAPI()
register_exception_handlers(app)
这样,全局异常处理器就注册好了。
八、分层怎么写
全局异常处理不是单独存在的,它要配合项目分层一起使用。
1. controller 层
controller 负责接收请求、调用 service、返回结果。
controller 不要写大量业务判断,也不要到处写 try...except。
python
# app/api/user.py
from fastapi import APIRouter
from app.core.response import success_response
from app.schemas.user import UserCreate
from app.services.user_service import create_user
router = APIRouter(prefix="/users", tags=["用户"])
@router.post("")
async def create_user_api(form: UserCreate):
user = await create_user(form)
return success_response(data=user, message="创建用户成功")
controller 这层保持简单,异常不在这里展开处理。
2. service 层
service 负责业务规则判断。业务不成立时,直接抛出业务异常。
python
# app/services/user_service.py
from app.core.exceptions import BusinessException
from app.crud.user_crud import get_user_by_username, save_user
from app.schemas.user import UserCreate
async def create_user(form: UserCreate):
exists_user = await get_user_by_username(form.username)
if exists_user:
raise BusinessException(
message="用户名已存在",
code=40001,
status_code=400,
)
return await save_user(form)
这段代码的业务流程很清楚:
查询用户名是否存在 -> 已存在就抛业务异常 -> 不存在就创建用户
不需要在 service 里直接返回 JSONResponse。
3. crud 层
crud 负责数据库操作,尽量保持简单。
python
# app/crud/user_crud.py
async def get_user_by_username(username: str):
return await User.filter(username=username).first()
async def save_user(form):
return await User.create(**form.model_dump())
如果你使用的是 Pydantic v1,可以把 model_dump() 换成 dict():
python
return await User.create(**form.dict())
crud 层一般不写复杂业务判断。除非是数据库唯一约束、事务回滚、连接异常这类和数据访问强相关的逻辑,否则业务规则建议放在 service 层。
九、兜底异常处理为什么重要
很多项目一开始只处理业务异常,但没有处理未知异常。这样线上一旦出现未预期错误,可能会返回很不稳定的响应,甚至暴露内部信息。
兜底异常处理器的作用是:
- 后端日志记录真实异常。
- 前端收到统一的错误格式。
- 避免把数据库错误、文件路径、堆栈信息暴露给用户。
推荐写法:
python
async def unknown_exception_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
logger.exception("未处理的系统异常: %s %s", request.method, request.url)
return JSONResponse(
status_code=500,
content=error_response(
code=50000,
message="服务器内部错误",
),
)
不推荐这样写:
python
return JSONResponse(
status_code=500,
content={"message": str(exc)},
)
原因很简单:str(exc) 不是给前端看的,它可能包含数据库表名、SQL、文件路径、第三方服务返回的敏感信息。
十、什么时候才需要 try...except
全局异常处理不代表项目里完全不能写 try...except。
适合写 try...except 的场景包括:
- 调用第三方接口,需要把超时转换成业务异常。
- 操作文件、消息队列、Redis 等外部资源。
- 需要做失败补偿,比如回滚、释放资源。
- 需要记录更详细的上下文日志。
例如调用支付服务:
python
async def call_payment_api(order_id: int):
try:
return await payment_client.pay(order_id)
except TimeoutError as exc:
raise BusinessException(
message="支付服务超时,请稍后重试",
code=50010,
status_code=503,
) from exc
这里有两个关键点:
- 捕获异常后不要直接吞掉。
- 使用
raise ... from exc保留异常链,方便排查原始错误。
十一、不同异常应该返回什么状态码
可以参考下面这个表:
| 场景 | HTTP 状态码 | 说明 |
|---|---|---|
| 参数校验失败 | 422 或 400 | FastAPI 默认常用 422 |
| 未登录 | 401 | 没有有效身份认证 |
| 无权限 | 403 | 已登录但无权限 |
| 资源不存在 | 404 | 查询对象不存在 |
| 业务规则不通过 | 400 | 比如用户名已存在、余额不足 |
| 第三方服务不可用 | 503 | 比如支付服务超时 |
| 未知系统异常 | 500 | 服务器内部错误 |
业务状态码可以自己约定,比如:
| 业务 code | 含义 |
|---|---|
0 |
成功 |
40000 |
通用业务错误 |
40001 |
用户名已存在 |
40100 |
未登录 |
40300 |
无权限 |
42200 |
参数校验失败 |
50000 |
服务器内部错误 |
具体编号不重要,重要的是团队内部保持一致。
十二、常见错误
1. 每个接口都写 try...except
这样会导致重复代码很多,而且每个接口的错误格式容易不一致。
更好的方式是:业务错误抛 BusinessException,统一异常处理器负责返回响应。
2. service 里直接返回 JSONResponse
service 层不应该关心 HTTP 响应。它应该只关心业务流程。
不推荐:
python
async def create_user(form):
if exists_user:
return JSONResponse(...)
推荐:
python
async def create_user(form):
if exists_user:
raise BusinessException(message="用户名已存在", code=40001)
3. 把所有异常都返回 500
用户名已存在、参数错误、权限不足都不是 500。500 表示服务器内部错误,不适合表示普通业务失败。
4. 把原始异常直接返回给前端
错误信息应该分两层:
- 日志里记录真实异常。
- 前端只返回安全、稳定、可理解的提示。
5. crud 层写大量业务判断
crud 层负责数据访问,service 层负责业务规则。把大量业务判断放在 crud 层,会让代码后期很难复用。
十三、完整流程回顾
整个请求流程可以理解成:
python
前端请求
-> controller 接收参数
-> service 执行业务流程
-> crud 查询或写入数据库
-> 如果业务不成立,service raise BusinessException
-> exception handler 捕获异常
-> 返回统一 JSON 响应
职责可以总结成下面这张表:
| 层级 | 职责 | 是否建议处理异常 |
|---|---|---|
| controller | 接收请求、调用 service | 不建议大量 try...except |
| service | 写业务流程和业务判断 | 可以 raise BusinessException |
| crud | 数据库操作 | 尽量保持简单 |
| exception handler | 统一转换异常响应 | 专门处理异常 |
| logger | 记录真实错误 | 记录系统异常细节 |
十四、总结
FastAPI 全局异常处理的重点不是"把所有错误都捕获掉",而是让项目的错误处理有清晰边界。
推荐原则:
- controller 只写接口入口。
- service 写业务流程,业务不成立就
raise。 - crud 只写数据访问,不混入 HTTP 响应。
- 自定义业务异常只保存错误信息。
- exception handler 统一把异常转换成响应。
- 未知异常要兜底,并记录日志。
最终目标是:
业务代码保持 basic flow,异常逻辑集中处理,前端响应格式稳定,线上问题可以通过日志排查。