统一异常处理
前面你已经见过两种错误出口:02 章的 422 校验错误、路由函数里随手抛的 HTTPException。服务小的时候,各管各的没事;服务一大,错误出口就成了「八国联军」——有的接口回 {"detail": ...},有…
前面你已经见过两种错误出口:02 章的 422 校验错误、路由函数里随手抛的
HTTPException。服务小的时候,各管各的没事;服务一大,错误出口就成了「八国联军」——有的接口回{"detail": ...},有的回{"detail": [...]},有的直接一句纯文本Internal Server Error,前端得为每种格式写一套解析。本章的任务:把所有异常收拢到一个出口,让每个错误都开成同一张「病历单」。示例场景仍是墨集 inkhub 内容平台——栏目、文章、版本的世界里,错误同样无处不在。
本章你会用到的东西
- 服务端异常处理的三条家规(不裸奔、不泄密、一个出口)
- FastAPI 默认异常链路:
HTTPException/RequestValidationError/ 未捕获Exception @app.exception_handler:接管某类异常的出口BusinessException基类:code/message/default_message/http_status- 子类异常体系:三行代码新增一种错误
- 错误码设计:HTTP 状态码 + 序号(楼层 + 房间号)
- SQLAlchemy
IntegrityError转译成人话 - 注册中心:一处注册,全家生效
- 错误码表自动注入
/docs
9.1 三条家规:出了错,先守规矩
是什么:把服务端异常处理想象成医院的急诊分诊台。后厨(业务代码)哪个环节炸锅都行,但端到病人面前的,必须是分诊台开出的标准病历单。动手写代码之前,先立好三条家规。
家规一:不裸奔。任何异常都不能把服务器干崩——一个请求炸了,服务本身还得活着接下一个;同时客户端不该看到堆栈,Internal Server Error 这种纯文本对用户毫无价值。
家规二:不泄密。堆栈、SQL 语句、表名、约束名、内部路径,统统不许出现在响应里。数据库报错原文经常长这样:duplicate key value violates unique constraint "uq_article_slug"——这句话里泄露的信息够攻击者画半张系统结构图。
家规三:一个出口。所有错误响应只有一个形状:HTTP 状态码 + 业务错误码 code + 人话 message。不管错误从哪来,出了门都长一个样。
顺带分清两类异常,后面所有设计都围绕这个区分展开:
| 业务异常 | 系统异常 | |
|---|---|---|
| 例子 | 文章不存在、slug 已被占用、栏目已下架 | 数据库断连、代码 bug、磁盘满 |
| 谁能懂 | 用户能看懂,能照着提示行动 | 只有开发者能懂,用户看了也白看 |
| 分诊台怎么处理 | 直接告诉病人:「挂这个科」 | 关门抢救,家属只收到「正在处理,请稍候」 |
| 响应里的 message | 具体的人话(文章不存在) |
笼统的话(服务器开小差了,请稍后重试) |
| 堆栈 | 一般没有 | 进服务端日志,绝不进响应 |
常见坑:
FastAPI(debug=True)会在 500 响应里直接返回完整堆栈——开发利器,生产环境必须关掉,这是家规二的头号违反者。- 「全接口 200,错误写在 body 里」的反模式(02 章提过)在本章有了另一半解法:错误走状态码 + 统一格式,成功怎么统一是下一章中间件的事。
9.2 默认异常链路:FastAPI 出错时都发生了什么
是什么:一条请求进到 FastAPI,路上有三道关卡会抛异常。不接管它们之前,得先知道框架默认把每类异常送到了哪个出口。
三道关卡:
- 路由匹配关:路径不存在 → 框架抛
HTTPException(404);方法不对(对/articles发 DELETE)→ 抛HTTPException(405)。 - 参数校验关:Pydantic 校验失败 → 抛
RequestValidationError→ 422。02 章你反复见过的那个 422 就是它。 - 你的函数关:你写的代码里抛什么,就是什么——
HTTPException、自定义异常、或者一个没人管的AttributeError。
默认出口对照表(接管前,四种异常四种长相):
| 异常 | 谁抛的 | 状态码 | 默认响应体 |
|---|---|---|---|
HTTPException |
你主动抛 | 你指定的 | {"detail": "你给的消息"} |
RequestValidationError |
框架(校验失败) | 422 | {"detail": [{"type": ..., "loc": [...], "msg": ...}, ...]} |
HTTPException |
框架(路由/方法不匹配) | 404 / 405 | {"detail": "Not Found"} |
其他一切 Exception |
不可预期(bug、断连) | 500 | 纯文本 Internal Server Error |
最小实验:写一个没有任何异常处理的 app,三个请求看三种出口。
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="墨集 inkhub 内容平台")
class ArticleIn(BaseModel):
title: str = Field(min_length=1)
@app.get("/articles/{article_id}")
def get_article(article_id: int) -> dict[str, int]:
if article_id != 1:
raise HTTPException(status_code=404, detail="文章不存在")
return {"article_id": article_id}
@app.post("/articles")
def create_article(article: ArticleIn) -> dict[str, str]:
return {"title": article.title}
curl http://127.0.0.1:8000/articles/2
# 404 → {"detail":"文章不存在"}
curl http://127.0.0.1:8000/articles/abc
# 422 → {"detail":[{"type":"int_parsing","loc":["path","article_id"],"msg":"Input should be a valid integer, ...", ...}]}
curl http://127.0.0.1:8000/no/such/path
# 404 → {"detail":"Not Found"}
三种出口三种格式:detail 一会儿是字符串、一会儿是数组,第三个干脆是英文黑话。这就是要统一的东西。
常见坑:
- 500 的堆栈去哪了?——在 uvicorn 终端日志里(
Exception in ASGI application后面跟完整 traceback),响应里没有。排查 500 先看终端,别盯着响应猜。 fastapi.HTTPException和starlette.exceptions.HTTPException是父子关系(前者继承后者)。框架自己抛的 404/405 是 Starlette 那个,这个细节在 9.3 节会坑到人。
9.3 @app.exception_handler:接管异常出口
作用:告诉 FastAPI「这类异常归我管,响应我自己写」。注册之后,框架默认的出口就被你的函数替换掉了。
签名(两种写法,效果完全相同):
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
# 写法一:装饰器
@app.exception_handler(异常类)
async def any_name(request: Request, exc: 异常类) -> JSONResponse:
... # 返回任意 Response 对象
# 写法二:方法调用(适合集中注册,见 9.8 节)
app.add_exception_handler(异常类, any_name)
最小示例:接管 RequestValidationError,把 422 换成自己的格式。
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI(title="墨集 inkhub 内容平台")
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
# exc.errors() 拿到错误列表,每条含 type / loc / msg
parts = [
f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}"
for err in exc.errors()
]
return JSONResponse(
status_code=422,
content={"code": "422000", "message": ";".join(parts)},
)
loc 是错误位置的路径元组:("body", "title") 说明请求体的 title 字段挂了,("path", "article_id") 说明路径参数挂了——拼进 message,前端一眼定位。
匹配规则(本章的地基):异常发生时,Starlette 拿着 type(exc).__mro__(异常类的继承链,从具体到一般)挨个问「这个类注册过 handler 吗」,命中第一个就用。翻译成人话:
给基类注册 handler,全体子类自动生效。
raise ArticleNotFound() 时,处理器列表里没有 ArticleNotFound,但它的继承链上有 BusinessException——命中。这就是后面「一处注册,全家生效」的底层依据。
常见坑:
- handler 必须返回
Response对象(JSONResponse等)。return {"code": ...}这种 dict 不会帮你序列化,会直接炸出一个新异常。 - 要接管框架自己抛的 404/405,注册的必须是
starlette.exceptions.HTTPException(父类)。只注册fastapi.HTTPException的话,MRO 查找对不上号——Starlette 抛的异常的继承链里没有 fastapi 那个子类,会被漏掉。 - 注册
Exception兜底后,uvicorn 终端照样打完整堆栈——Starlette 处理完会把异常再抛给服务器记录。客户端看不到、日志还在,这是设计不是 bug。 - 同一异常类注册两次,后注册的覆盖先注册的(就是个字典赋值)。
- exception_handler 只管「请求处理过程」中的异常;
lifespan(启动/关闭钩子)里抛的异常不走这套机制。handler 自己也别再抛异常——出口处再炸,就真没有出口了。
9.4 BusinessException:给业务异常发工牌
作用:业务层抛异常时,不应该操心「这事儿对应 404 还是 409」——那是异常类自己的事。基类把 HTTP 状态码、业务错误码、默认消息做成三张缝在工服上的工牌,谁抛异常就穿谁的工服,处理时照牌办事。
签名(完整基类,可以直接抄走):
class BusinessException(Exception):
"""业务异常基类:所有「能向用户解释」的错误都从它派生。"""
http_status: int = 400 # 映射的 HTTP 状态码
code: str = "400000" # 业务错误码
default_message: str = "业务处理失败" # 默认人话消息
def __init__(self, message: str | None = None, *, detail: str = "") -> None:
# 不传消息 → 用类上的 default_message,raise 处一行搞定
self.message = message or self.default_message
# detail 是写给开发者的内部线索:只进日志,绝不进响应
self.detail = detail
super().__init__(self.message)
最小示例:
raise BusinessException() # 400 / 400000 / 业务处理失败
raise BusinessException("专栏订阅数已达上限") # 临时覆盖消息
为什么不直接用 HTTPException 干到底:
| 维度 | HTTPException |
BusinessException |
|---|---|---|
| 词汇 | 协议词汇(404、409、detail) | 业务词汇(文章不存在、slug 冲突) |
| 消息 | 每次抛都要手写 detail | default_message 兜底,可省略 |
| 错误码 | 只有 HTTP 状态码,不够分 | 自带业务 code |
| 可扩展 | 没法继承定制 | 子类体系,三行代码一种新错误(9.5 节) |
| 适合的层 | 路由层、简单场景 | 业务层、服务层 |
一句话:HTTPException 说的是「协议上该回什么」,BusinessException 说的是「业务上发生了什么」。服务复杂之后,业务层不该懂协议。
常见坑:
- 忘写
super().__init__(self.message)——异常的基本功能(str(exc)、日志打印)会残缺。 - 把
message设成必传参数,害得每个raise都得憋一句话。默认机制的价值就在于:类上写一次,抛处零负担。 detail和message别搞混:message给用户看,detail给日志看。detail里的内容(比如数据库报错原文)永远不出现在响应里。
9.5 子类体系与 default_message:三行代码新增一种错误
是什么:基类定调,子类只改牌子。每个子类覆盖 code 和 default_message,http_status 能继承就继承——这就是 default_message 机制的全部内容。
墨集的异常树:
BusinessException # 400 / 400000 / 业务处理失败
├── NotFoundError # 404 / 404000 / 资源不存在
│ ├── ArticleNotFound # 404 / 404001 / 文章不存在
│ ├── ChannelNotFound # 404 / 404002 / 栏目不存在
│ └── VersionNotFound # 404 / 404003 / 文章版本不存在
└── ConflictError # 409 / 409000 / 数据冲突
└── ArticleSlugExists # 409 / 409001 / 文章 slug 已被占用
最小示例:
class NotFoundError(BusinessException):
"""「资源不存在」家族:404 楼层的通用房间。"""
http_status = 404
code = "404000"
default_message = "资源不存在"
class ArticleNotFound(NotFoundError):
code = "404001" # 只需要两行:换房间号、换默认消息
default_message = "文章不存在"
class ChannelNotFound(NotFoundError):
code = "404002"
default_message = "栏目不存在"
class VersionNotFound(NotFoundError):
code = "404003"
default_message = "文章版本不存在"
raise 的三种姿势:
raise ArticleNotFound() # 用默认消息
raise ArticleNotFound("文章不存在或已被作者删除") # 临时覆盖:同一种错误,场景化提示
raise ArticleSlugExists(detail="dup key: fastapi-exception") # 带内部线索,只进日志
handler 只写一次。因为 9.3 节的 MRO 规则,一个针对 BusinessException 的处理器接住整棵树:
@app.exception_handler(BusinessException)
async def business_handler(request: Request, exc: BusinessException) -> JSONResponse:
return JSONResponse(
status_code=exc.http_status,
content={"code": exc.code, "message": exc.message},
)
以后新增 AuthorNotFound、TagNotFound……只加异常类,handler 一字不改。
常见坑:
- 子类忘了改
code,两种异常共用一个码——前端拿到404001分不清是文章没了还是栏目没了。 default_message是类属性,直接在类体里赋值就行,别搬进__init__(那是每个实例的活儿,白白多写代码)。- 异常类名要见名知义:
ArticleNotFound自带说明书,MyError1谁也看不懂。 - 中间层(
NotFoundError)不是多余的:它让「家族通用兜底」成为可能——某处只关心「是不是不存在」时,except NotFoundError一网打尽。
9.6 错误码设计:楼层 + 房间号
是什么:code 的编码规则。把 HTTP 状态码想象成楼层号,把三位序号想象成房间号:
404001
└┬┘└┬┘
│ └── 房间号:404 楼下的第 1 号场景(文章不存在)
└─── 楼层号:HTTP 404
墨集的错误码表:
| 错误码 | HTTP 状态码 | 场景 |
|---|---|---|
404000 |
404 | 资源不存在(家族通用) |
404001 |
404 | 文章不存在 |
404002 |
404 | 栏目不存在 |
404003 |
404 | 文章版本不存在 |
409000 |
409 | 数据冲突(家族通用) |
409001 |
409 | 文章 slug 已被占用 |
422000 |
422 | 请求校验失败(框架异常,见 9.8 节的注册中心) |
500000 |
500 | 服务器内部错误(兜底) |
404 / 405 / … |
原状态码 | 协议层错误(路径不存在、方法不允许) |
注意最后一行:协议层错误不带房间号,code 直接用状态码本身。这样客户端一眼区分两种 404——code: "404" 是「你请求的路径本身不存在」(URL 写错了),code: "404001" 是「路径存在,但资源没了」(正常业务状况)。前者找前端,后者找用户。
为什么必须有 code:HTTP 状态码总共就几十个,业务场景成百上千;前端要按 code 弹精确提示、做埋点统计,日志系统要按 code 聚合报警。状态码管「大类」,code 管「细节」。
常见坑:
- 全局自增码(
10001、10002……)看不出类别,排障时对着一张千行对照表找号,不如楼层房间号直观。 - code 一旦发布就是客户端契约,中途改动等于毁约。宁可一开始多想一步,也别发布后再改。
- 别编码过多语义搞出
404001101这种长码——三段(状态码 + 三位序号)够用到大部分服务退役。
9.7 IntegrityError 转译:数据库黑话翻译官
是什么:数据库的报错是「方言」——IntegrityError 的原文里全是 SQL 语句、表名、约束名。按家规二,这些不能出门。转译 = 让 sqlalchemy.exc.IntegrityError 在服务层就被翻译成业务异常。
场景:文章的 slug 建了唯一约束。并发场景下,两个请求同时插入同一个 slug,应用层的 if 检查会双双通过(都查到「不存在」),最后由数据库约束兜住,抛出 IntegrityError。
姿势一(推荐):服务层转译——能区分具体场景。
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
async def create_article(article: ArticleIn, session: AsyncSession) -> None:
session.add(Article(**article.model_dump()))
try:
await session.commit()
except IntegrityError as exc:
# 数据库黑话 → 人话;原文挂到 detail,只进日志
raise ArticleSlugExists(detail=str(exc.orig)) from exc
姿势二(保底):全局 handler 兜底——防漏网之鱼。
from sqlalchemy.exc import IntegrityError
@app.exception_handler(IntegrityError)
async def integrity_handler(request: Request, exc: IntegrityError) -> JSONResponse:
# 分不清具体是哪种约束冲突,只能给家族通用的笼统话
return JSONResponse(
status_code=409,
content={"code": "409000", "message": "数据冲突"},
)
两种姿势怎么选:
| 服务层转译 | 全局兜底 | |
|---|---|---|
| 消息精度 | 精确(slug 已被占用) |
笼统(数据冲突) |
| 错误码精度 | 具体(409001) |
家族通用(409000) |
| 覆盖面 | 只覆盖写了 try/except 的地方 | 所有落库操作 |
| 定位 | 主力 | 保险 |
常见坑:
IntegrityError是一大类:唯一约束、外键约束、非空约束撞了全是它。全局兜底只能给笼统消息,想精确就得在服务层按场景转译。- 转译时千万别把原始报错塞进
message——str(exc.orig)里带着完整 SQL 和表结构,进响应就是泄密事故;它属于detail,属于日志。 raise ... from exc保留异常因果链,日志里能看到「业务异常 ← 数据库异常」的完整链条,别丢。
9.8 注册中心:一处注册,全家生效
是什么:把所有 exception_handler 的注册收拢到一个函数里,main.py 一行调用。相当于分诊台只设一个总机——所有分机的登记都在总机这一处完成,别每层楼各开一个小前台。
最小示例(本章 demo 的实际写法):
def register_exception_handlers(app: FastAPI) -> None:
"""给 app 装上全套异常处理器:四个出口,一种形状。"""
@app.exception_handler(BusinessException)
async def handle_business_exception(
request: Request, exc: BusinessException
) -> JSONResponse:
return error_response(exc.code, exc.message, exc.http_status)
@app.exception_handler(RequestValidationError)
async def handle_validation_exception(
request: Request, exc: RequestValidationError
) -> JSONResponse:
message = _format_validation_message(exc) or "请求数据校验失败"
return error_response("422000", message, 422)
@app.exception_handler(HTTPException) # starlette 的那个!
async def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
message = PROTOCOL_MESSAGES.get(exc.status_code, str(exc.detail))
return error_response(str(exc.status_code), message, exc.status_code)
@app.exception_handler(Exception)
async def handle_unknown_exception(request: Request, exc: Exception) -> JSONResponse:
return error_response("500000", "服务器开小差了,请稍后重试", 500)
# main.py
app = FastAPI(title="墨集 inkhub 内容平台")
register_exception_handlers(app) # 就这一行
四个出口对应四种来路,出门全是 {"code": ..., "message": ...}:
| 出口 | 接住谁 | code 从哪来 |
|---|---|---|
| 业务异常 handler | BusinessException 全家 |
异常类自带的 code |
| 校验异常 handler | RequestValidationError |
固定 422000 |
| 协议异常 handler | HTTPException(含框架抛的) |
状态码本身("404") |
| 兜底 handler | 一切漏网异常 | 固定 500000 |
常见坑:
- 漏注册
Exception兜底,未知异常就回到纯文本Internal Server Error时代——四个出口一个都不能少。 - 注册时机放在
app创建后尽早调用(对路由注册顺序不敏感,但统一放最前面,避免「以为注册了其实没有」的错觉)。 - handler 里别做重活——出口要快。一个慢出口会把「报错」拖成「事故」。
9.9 错误码表自动进 /docs
是什么:错误码表不该躺在外部 wiki 里吃灰。用 FastAPI(description=...) 把它渲染进 /docs 首页,而且从异常类自动生成——新增一个异常类,文档表格自动多一行,永不失联。
原理:description 支持 Markdown(表格直接渲染);cls.__subclasses__() 能列出子类,递归一层就能走完整棵异常树。
最小示例:
from collections.abc import Iterator
def _iter_exception_tree(cls: type[BusinessException]) -> Iterator[type[BusinessException]]:
"""递归遍历所有子类:__subclasses__() 只给直接子类,孙类要自己往下挖。"""
for sub in cls.__subclasses__():
yield sub
yield from _iter_exception_tree(sub)
def build_error_docs() -> str:
lines = [
"统一错误响应格式:`{\"code\": \"业务错误码\", \"message\": \"人话消息\"}`。",
"",
"| 错误码 | HTTP 状态码 | 默认消息 | 异常类 |",
"| --- | --- | --- | --- |",
]
for exc_type in _iter_exception_tree(BusinessException):
lines.append(
f"| {exc_type.code} | {exc_type.http_status} | "
f"{exc_type.default_message} | {exc_type.__name__} |"
)
return "\n".join(lines)
app = FastAPI(
title="墨集 inkhub 内容平台",
description="统一异常处理演示。\n\n" + build_error_docs(),
)
实际渲染效果(打开 /docs 首页就能看到):
| 错误码 | HTTP 状态码 | 默认消息 | 异常类 |
|---|---|---|---|
| 404000 | 404 | 资源不存在 | NotFoundError |
| 404001 | 404 | 文章不存在 | ArticleNotFound |
| 404002 | 404 | 栏目不存在 | ChannelNotFound |
| 404003 | 404 | 文章版本不存在 | VersionNotFound |
| 409000 | 409 | 数据冲突 | ConflictError |
| 409001 | 409 | 文章 slug 已被占用 | ArticleSlugExists |
常见坑:
__subclasses__()只列直接子类,BusinessException下面的孙类(ArticleNotFound)必须递归才能拿到。- 单个接口的 404/409 说明还可以用装饰器的
responses={"404": {"description": "文章不存在"}}补充(02 章的写法),和首页的码表是总分关系,不冲突。
9.10 小结速查
| 你想做什么 | 用什么 |
|---|---|
| 接管某类异常的响应 | @app.exception_handler(异常类) 或 app.add_exception_handler(...) |
| 让全体子类异常共用一个 handler | 给基类注册(MRO 查找,从具体到一般) |
| 接管框架抛的 404/405 | 注册 starlette.exceptions.HTTPException |
| 业务异常基类 | BusinessException:http_status / code / default_message |
| 新增一种业务错误 | 子类覆盖 code + default_message,三行搞定 |
| 错误码规则 | 前 3 位 HTTP 状态码 + 3 位序号(404001) |
| 数据库约束冲突转人话 | 服务层 except IntegrityError → 业务异常 + from exc |
| 集中注册 | register_exception_handlers(app),main 里一行 |
| 错误码表进文档 | __subclasses__() 递归生成 → FastAPI(description=...) |
配套代码:demo/demo_exception.py——单文件演示完整链路:异常体系 + 四个 handler + 统一错误响应 + 错误码表进文档,内嵌 uvicorn,注释里写全了 curl 示例。
cd demo
python demo_exception.py
三种返回的真实对比(以下输出全部实测):
# ① 合法请求 → 200
curl -i http://127.0.0.1:8000/articles/1
# {"id":1,"title":"FastAPI 异常处理指南","slug":"fastapi-exception","channel_id":1,"author_id":1}
# ② 文章不存在 → 404
curl -i http://127.0.0.1:8000/articles/999
# {"code":"404001","message":"文章不存在"}
# ③ 校验失败 → 422(路径参数 abc 过不了 int 校验)
curl -i http://127.0.0.1:8000/articles/abc
# {"code":"422000","message":"path.article_id: Input should be a valid integer, unable to parse string as an integer"}
demo 里还埋了几个「彩蛋接口」,分别验证:栏目/版本的 404002/404003、slug 冲突的 409001、请求体校验的字段级明细、协议错误 code:"404"、毒丸数据触发的未知异常兜底 500000——每个坑位在文件头部的 curl 清单里都有对应命令。
作业
- FastAPI 默认的三种异常出口(
HTTPException/RequestValidationError/ 未捕获异常),响应格式分别长什么样?为什么说它们「不统一」? - 给
BusinessException注册一个 handler,为什么ArticleNotFound也能被接住?背后的查找规则是什么? - 注册
HTTPException的 handler 时,为什么要导入starlette.exceptions.HTTPException而不是fastapi.HTTPException? - 错误码
404002怎么读?它和code为"404"的响应有什么区别?各自指向谁的责任? - 未知异常兜底 handler 返回统一格式后,完整堆栈去哪了?开发时应该去哪看?
本章统一了出错时怎么说话。那成功的时候呢?——响应格式同样需要统一信封({"code": 0, "data": ..., "message": "ok"} 这类包装),那活儿不适合在每个接口里手写,适合在一个所有请求都会路过的地方统一做。
下一章:10 · 中间件