首页/Python 框架指南/统一异常处理
Python 框架指南 · 第 09 章

统一异常处理

前面你已经见过两种错误出口:02 章的 422 校验错误、路由函数里随手抛的 HTTPException。服务小的时候,各管各的没事;服务一大,错误出口就成了「八国联军」——有的接口回 {"detail": ...},有…

约 36 分钟14,331 字18 个代码示例

前面你已经见过两种错误出口: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 具体的人话(文章不存在) 笼统的话(服务器开小差了,请稍后重试)
堆栈 一般没有 进服务端日志,绝不进响应

常见坑:

  1. FastAPI(debug=True) 会在 500 响应里直接返回完整堆栈——开发利器,生产环境必须关掉,这是家规二的头号违反者。
  2. 「全接口 200,错误写在 body 里」的反模式(02 章提过)在本章有了另一半解法:错误走状态码 + 统一格式,成功怎么统一是下一章中间件的事。

9.2 默认异常链路:FastAPI 出错时都发生了什么

是什么:一条请求进到 FastAPI,路上有三道关卡会抛异常。不接管它们之前,得先知道框架默认把每类异常送到了哪个出口。

三道关卡:

  1. 路由匹配关:路径不存在 → 框架抛 HTTPException(404);方法不对(对 /articles 发 DELETE)→ 抛 HTTPException(405)。
  2. 参数校验关:Pydantic 校验失败 → 抛 RequestValidationError → 422。02 章你反复见过的那个 422 就是它。
  3. 你的函数关:你写的代码里抛什么,就是什么——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 一会儿是字符串、一会儿是数组,第三个干脆是英文黑话。这就是要统一的东西。

常见坑:

  1. 500 的堆栈去哪了?——在 uvicorn 终端日志里(Exception in ASGI application 后面跟完整 traceback),响应里没有。排查 500 先看终端,别盯着响应猜。
  2. 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——命中。这就是后面「一处注册,全家生效」的底层依据。

常见坑:

  1. handler 必须返回 Response 对象(JSONResponse 等)。return {"code": ...} 这种 dict 不会帮你序列化,会直接炸出一个新异常。
  2. 要接管框架自己抛的 404/405,注册的必须是 starlette.exceptions.HTTPException(父类)。只注册 fastapi.HTTPException 的话,MRO 查找对不上号——Starlette 抛的异常的继承链里没有 fastapi 那个子类,会被漏掉。
  3. 注册 Exception 兜底后,uvicorn 终端照样打完整堆栈——Starlette 处理完会把异常再抛给服务器记录。客户端看不到、日志还在,这是设计不是 bug。
  4. 同一异常类注册两次,后注册的覆盖先注册的(就是个字典赋值)。
  5. 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 说的是「业务上发生了什么」。服务复杂之后,业务层不该懂协议。

常见坑:

  1. 忘写 super().__init__(self.message)——异常的基本功能(str(exc)、日志打印)会残缺。
  2. 把 message 设成必传参数,害得每个 raise 都得憋一句话。默认机制的价值就在于:类上写一次,抛处零负担。
  3. 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 一字不改。

常见坑:

  1. 子类忘了改 code,两种异常共用一个码——前端拿到 404001 分不清是文章没了还是栏目没了。
  2. default_message 是类属性,直接在类体里赋值就行,别搬进 __init__(那是每个实例的活儿,白白多写代码)。
  3. 异常类名要见名知义:ArticleNotFound 自带说明书,MyError1 谁也看不懂。
  4. 中间层(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 管「细节」。

常见坑:

  1. 全局自增码(10001、10002……)看不出类别,排障时对着一张千行对照表找号,不如楼层房间号直观。
  2. code 一旦发布就是客户端契约,中途改动等于毁约。宁可一开始多想一步,也别发布后再改。
  3. 别编码过多语义搞出 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 的地方 所有落库操作
定位 主力 保险

常见坑:

  1. IntegrityError 是一大类:唯一约束、外键约束、非空约束撞了全是它。全局兜底只能给笼统消息,想精确就得在服务层按场景转译。
  2. 转译时千万别把原始报错塞进 message——str(exc.orig) 里带着完整 SQL 和表结构,进响应就是泄密事故;它属于 detail,属于日志。
  3. 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

常见坑:

  1. 漏注册 Exception 兜底,未知异常就回到纯文本 Internal Server Error 时代——四个出口一个都不能少。
  2. 注册时机放在 app 创建后尽早调用(对路由注册顺序不敏感,但统一放最前面,避免「以为注册了其实没有」的错觉)。
  3. 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

常见坑:

  1. __subclasses__() 只列直接子类,BusinessException 下面的孙类(ArticleNotFound)必须递归才能拿到。
  2. 单个接口的 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 清单里都有对应命令。

作业

  1. FastAPI 默认的三种异常出口(HTTPException / RequestValidationError / 未捕获异常),响应格式分别长什么样?为什么说它们「不统一」?
  2. 给 BusinessException 注册一个 handler,为什么 ArticleNotFound 也能被接住?背后的查找规则是什么?
  3. 注册 HTTPException 的 handler 时,为什么要导入 starlette.exceptions.HTTPException 而不是 fastapi.HTTPException?
  4. 错误码 404002 怎么读?它和 code 为 "404" 的响应有什么区别?各自指向谁的责任?
  5. 未知异常兜底 handler 返回统一格式后,完整堆栈去哪了?开发时应该去哪看?

本章统一了出错时怎么说话。那成功的时候呢?——响应格式同样需要统一信封({"code": 0, "data": ..., "message": "ok"} 这类包装),那活儿不适合在每个接口里手写,适合在一个所有请求都会路过的地方统一做。

下一章:10 · 中间件