全部返回 400,客户端到底该改什么?¶
上一章结束时,我们已经知道 Pydantic 校验失败会产生结构化的 ValidationError,
精确告诉客户端"哪个字段、什么类型、因为什么原因被拒绝"。FastAPI 把它变成
HTTP 422 响应,一切看起来井然有序。
但这只是失败世界的冰山一角。
AI 学习助手的 build_plan 在真实运行中不只会遇到"字段类型错误"这一种失败。
有时客户端发了一个类型完全正确但业务上荒谬的请求(weekly_hours=0),有时
数据库连接突然断了。如果你把所有这些失败都塞进同一个 HTTP 400 和同一条
"Error" 消息里,客户端收到错误后会面临一个致命困境:我到底该修正输入、
改变请求参数,还是等服务恢复?
这一章就从这个困境开始。
运行环境¶
Python 3.12、FastAPI 0.115+、Pydantic v2。
1. 三次失败,同一个 400¶
假设客户端连续调用三次 POST /plans,每次因为不同原因失败:
第一次——字段类型错误:
第二次——类型正确但业务不允许:
第三次——请求本身完全合法,但服务器内存溢出导致计算无法完成:
如果服务端对这三种情况全部返回:
客户端开发者看到三次一模一样的 400 响应。先停下来想想:面对这三次失败, 客户端分别应该做什么?
- 第一次:输入格式有误 → 客户端应该修正
weekly_hours的类型 - 第二次:格式正确但值不合理 → 客户端应该改变业务参数(让用户填写大于 0 的值)
- 第三次:客户端什么都没做错 → 应该等一等再重试
三种完全不同的下一步动作,但客户端从响应中完全无法区分应该走哪条路。它只能 猜。猜错了代价是什么?用户看到"请修正输入"的提示,但实际上是服务端宕机了—— 用户反复修改表单也无济于事,只会越来越沮丧。
混淆归责的本质伤害:客户端无法判断失败属于谁的责任,也就无法选择正确的 恢复策略。错误分类的目标不是给状态码"分配名字",而是让客户端仅通过状态码和 响应体就能决定下一步动作。
2. 三类失败:用"客户端该做什么"来分类¶
既然问题的核心是"客户端不知道下一步该做什么",那分类的依据就应该是:这个 失败是谁的责任,客户端能修复吗?
把三次失败重新归类:
| 失败来源 | 归责 | HTTP 状态 | 客户端应做什么 | 示例 |
|---|---|---|---|---|
| 校验失败 | 客户端输入格式错误 | 422 | 修正输入类型 | "很多" 无法转为 int |
| 业务失败 | 客户端请求合法但不符业务前提 | 4xx(如 409/422) | 改变业务参数 | weekly_hours=0 不符业务规则 |
| 未预期故障 | 服务端自身问题 | 5xx | 等待服务恢复 | 内存溢出 |
分类的判据是一棵简单的决策树:
- 失败的来源在哪里?
- 客户端发送的数据本身不满足声明的类型约束 → 校验失败
- 数据类型正确,但不满足业务前提条件 → 业务失败
-
客户端的请求完全合法,是服务端自身出了问题 → 未预期故障
-
对应的 HTTP 状态族:
- 4xx 表示"客户端可修正的问题"——请求本身有问题
- 5xx 表示"服务端需要处理的问题"——请求没问题,服务有问题
FastAPI 默认把 Pydantic ValidationError 映射为 422——这覆盖了第一类。但第二类
和第三类需要你自己设计。
来做一个判断练习。AI 学习助手新增了一个约束:topic 不能为空字符串。Pydantic
模型允许空字符串(因为空字符串确实是合法的 str),但业务不允许。如果客户端
发了 {"topic": "", "weekly_hours": 6}——它属于哪类失败?
答案是业务失败。输入通过了类型校验(空字符串是 str),但不满足业务前提
(话题不能为空)。客户端应该改变业务参数——填一个有意义的话题。
这里有一个设计选择的空间:你也可以在 Pydantic 模型中用 Field(min_length=1)
把这个约束前置到校验层。如果一个规则只看字段值本身就能判断(上一章讨论过),
前置到模型是合理的。但这不改变分类框架本身——重要的是客户端收到的 HTTP 响应
能让它知道该做什么。
3. 从内部失败到公开响应的转换位置¶
知道了如何分类,下一个问题是:在 FastAPI 代码中,内部失败应该在哪里变成 HTTP 响应?
看 build_plan 的业务失败场景。核心逻辑检测到 weekly_hours 不满足业务前提
时,应该 raise 一个业务异常:
# core.py
class PlanCreationError(Exception):
"""业务规则拒绝创建计划。"""
def __init__(self, reason: str, field: str | None = None):
self.reason = reason
self.field = field
def build_plan(goal: LearningGoal) -> Plan:
if goal.weekly_hours <= 0:
raise PlanCreationError(
reason="weekly_hours must be positive",
field="weekly_hours",
)
daily_minutes = goal.weekly_hours * 60 // 7
return Plan(
topic=goal.topic,
daily_minutes=daily_minutes,
steps=[f"每天学习 {goal.topic} {daily_minutes} 分钟"],
)
注意 core 只抛出了一个普通 Python 异常——它不知道 HTTP 状态码的存在。转换 应该发生在更靠近 HTTP 边界的位置。
方式一:在适配层用 HTTPException 直接转换
# router.py
from fastapi import APIRouter, HTTPException
from core import build_plan, LearningGoal, PlanCreationError
router = APIRouter()
@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest):
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
try:
return build_plan(goal)
except PlanCreationError as e:
raise HTTPException(
status_code=422,
detail={
"error_code": "invalid_plan_params",
"message": e.reason,
"field": e.field,
},
)
HTTPException 在路由函数内部直接把业务异常转为 HTTP 响应。清晰、就地转换、
一眼能看到"这个异常会变成什么 HTTP 状态"。
方式二:注册 exception handler 统一处理
# error_handlers.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from core import PlanCreationError
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(PlanCreationError)
async def handle_plan_creation_error(
request: Request, exc: PlanCreationError
) -> JSONResponse:
return JSONResponse(
status_code=422,
content={
"error_code": "invalid_plan_params",
"message": exc.reason,
"field": exc.field,
},
)
exception handler 注册在应用级别,不管 PlanCreationError 从哪个路由冒出来,
都会被统一捕获并转换为相同的 HTTP 响应。
先想一个问题:如果未来增加 3 个路由(POST /goals、PUT /plans/{id}、
POST /plans/batch),每个都可能触发 PlanCreationError,用哪种方式更容易
保持一致?
答案是 exception handler。如果用方式一,你得在每个路由里写几乎相同的
try...except 块——错误结构、状态码、字段命名都要手动保持一致。一旦有人在
某个路由里把 error_code 拼成了 errorCode,客户端就会收到不一致的响应。
两种方式的选择依据:
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| 错误只在一个路由出现 | HTTPException |
简单直接,不需要额外注册 |
| 同一类错误跨多个路由重复 | exception handler | 统一转换逻辑,避免分散维护 |
| 需要修改全局错误格式 | exception handler | 改一处即可影响所有路由 |
核心认知:HTTPException 和 exception handler 都是"内部失败→公开 HTTP
响应"的转换位置。选择哪个取决于错误的复用范围,不是技术上哪个更"高级"。
4. 稳定的公开错误结构¶
有了转换机制后,还有一个容易被忽略的问题:不同路由返回的同类错误,形状 一样吗?
对比两个路由的业务失败响应:
// POST /plans 的业务失败
{"detail": "weekly_hours must be positive"}
// POST /goals 的业务失败
{"error": "invalid", "msg": "topic cannot be empty"}
两个都是"客户端请求合法但不满足业务前提"的错误。但字段名不一样——一个用
detail,一个用 error + msg。客户端必须为每个路由单独实现错误解析逻辑。
如果有 20 个路由,客户端代码里就有 20 种不同的错误解析分支。
稳定的公开错误结构意味着:同类错误在不同路由中使用一致的字段和形状。
为 AI 学习助手设计一个最小错误结构:
from pydantic import BaseModel
class ErrorResponse(BaseModel):
error_code: str # 机器可读的错误类别标识
message: str # 人类可读的错误描述
field: str | None = None # 出错的字段(如适用)
三类失败对应的响应:
校验失败(FastAPI 默认处理):
HTTP/1.1 422 Unprocessable Entity
{
"detail": [
{"type": "int_parsing", "loc": ["body", "weekly_hours"], "msg": "..."}
]
}
这是 FastAPI 的默认 422 格式,由框架内置的 RequestValidationError handler
生成。你可以覆盖它以匹配自己的结构,也可以保持默认——关键是客户端通过 422
状态码就知道"输入格式有问题,看 detail 里的字段定位修正"。
业务失败:
HTTP/1.1 422 Unprocessable Entity
{
"error_code": "invalid_plan_params",
"message": "weekly_hours must be positive",
"field": "weekly_hours"
}
未预期故障:
HTTP/1.1 500 Internal Server Error
{
"error_code": "internal_error",
"message": "An unexpected error occurred. Please try again later."
}
注意 500 响应不包含内部堆栈、异常类名或数据库连接字符串。5xx 只告诉客户端 "服务端出了问题,请稍后重试"——仅此而已。内部细节应该写入服务端日志供运维 排查,不应该通过 HTTP 响应泄露给外部。
把完整的转换用 exception handler 实现:
# error_handlers.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from core import PlanCreationError
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(PlanCreationError)
async def handle_plan_creation_error(
request: Request, exc: PlanCreationError
) -> JSONResponse:
return JSONResponse(
status_code=422,
content={
"error_code": "invalid_plan_params",
"message": exc.reason,
"field": exc.field,
},
)
@app.exception_handler(Exception)
async def handle_unexpected_error(
request: Request, exc: Exception
) -> JSONResponse:
# 生产环境中应在此处记录日志
return JSONResponse(
status_code=500,
content={
"error_code": "internal_error",
"message": "An unexpected error occurred. Please try again later.",
},
)
所有业务失败共享 error_code + message + field 结构,所有未预期故障共享
error_code + message 结构。客户端只需要一套解析逻辑就能处理所有路由的错误。
5. 正常和失败都应在公开签名中可见¶
回到 build_plan 的完整路径。现在它有三种可能的结果:
- 正常:返回 201 +
PlanResponse - 校验失败:返回 422 + 结构化校验错误(FastAPI 自动处理)
- 业务失败:返回 422 +
ErrorResponse(exception handler 处理)
看一下 typed core 中 build_plan 的函数签名:
def build_plan(goal: LearningGoal) -> Plan:
if goal.weekly_hours <= 0:
raise PlanCreationError(...)
...
return Plan(...)
它要么返回 Plan,要么 raise PlanCreationError。这个函数的行为是显式的:
正常走 return,失败走 raise——调用方可以从签名和代码中看到两条路径。
现在看一个反例:
def build_plan(goal: LearningGoal) -> Plan | None:
try:
if goal.weekly_hours <= 0:
return None # 悄悄吞掉了错误
...
return Plan(...)
except Exception:
return None # 所有异常都变成 None
从公开契约角度看,这有什么问题?
- 客户端收到
null后无法知道失败原因——和全部返回 400 的困境一样 try...except Exception把所有错误(包括编程 bug)都掩盖了——本该 暴露的内存溢出或类型错误被悄悄吞掉build_plan的调用方(适配层)无法区分"业务拒绝"和"代码 bug",因此也 无法给客户端正确的 HTTP 状态码
正确的做法是:函数要么返回声明的成功结果,要么 raise 声明的异常——不通过 catch-all 或返回无声明形状来掩盖错误。
这个原则不只是代码风格偏好。它直接影响公开契约的质量:只有当内部函数诚实地 暴露不同类型的失败时,适配层才能把它们映射为客户端可区分的 HTTP 响应。
那么错误结构设计完成后,还需要做什么才能让客户端在调用前就知道可能的错误? 答案是:已知错误必须进入接口描述(OpenAPI)。客户端不应该在运行时"遇到"错误 才知道其存在——它应该在阅读文档时就能看到所有可能的响应状态和结构。这是 P05 将展开的话题。
边界与常见误区¶
误区一:用 HTTP 200 包裹所有结果
这种设计让状态码失去意义。HTTP 客户端库通常根据状态码判断请求是否成功——
如果所有响应都是 200,客户端的 if response.ok 永远为真,错误处理代码
永远不会被触发。客户端必须深入解析响应体才能发现失败,而且每个调用方都要
手动检查 success 字段,忘了检查就是静默失败。
误区二:catch-all handler 把所有异常变成同一个 500
@app.exception_handler(Exception)
async def handle_all(request, exc):
return JSONResponse(status_code=500, content={"error": "something went wrong"})
这把可预期的业务拒绝(客户端的问题)也标记成了 5xx(服务端的问题)。客户端 收到 500 后的理性反应是"等待重试"——但实际上改一下参数就行了。混淆归责导致 错误的恢复策略。
正确做法:只让真正的未预期异常走 500,可预期的业务异常应该在 catch-all 之前 被更具体的 handler 捕获。
误区三:把 Pydantic 校验失败和业务拒绝用同一个状态码和结构返回
客户端收到 400 + "Invalid input",无法区分"是字段类型填错了"还是"值不满足
业务规则"。前者应该修正类型,后者应该改变值——这是两种不同的修正动作。即使
都用 4xx 状态码,至少应该通过 error_code 或结构差异让客户端程序化地区分。
误区四:在 500 响应中暴露内部实现细节
HTTP/1.1 500 Internal Server Error
{
"error": "MemoryError",
"traceback": "File \"/app/core.py\", line 42, in build_plan\n ..."
}
堆栈跟踪包含文件路径、行号、函数名——这些信息帮助攻击者理解你的代码结构。 5xx 响应应该只包含"服务端出了问题"的最小信息,详细堆栈应该只出现在服务端 日志中。
本章小结¶
从"所有失败都返回同一个 400"的困境出发,我们建立了错误分类和映射的框架。
三类失败有不同的来源和归责:校验失败是客户端输入不满足类型约束(422),业务 失败是输入合法但不满足业务前提(4xx),未预期故障是服务端自身异常(5xx)。 分类的依据不是状态码的数字,而是"客户端收到后应该做什么"。
HTTPException 和 exception handler 是将内部失败转换为公开 HTTP 响应的两个
位置。前者适合单路由的就地转换,后者适合跨路由的统一处理。核心原则是:内部
异常不应未经转换就直接变成 HTTP 响应。
公开错误结构应保持稳定:同类错误在不同路由使用一致的字段和形状。5xx 不暴露 内部堆栈——只告诉客户端"等一等"即可。
在 typed core 的实现中,函数应诚实地通过 return 表达成功、通过 raise 表达 声明的失败——不用 catch-all 或无声明返回掩盖错误。只有内部行为诚实暴露,适配 层才能把不同类型的失败正确映射为客户端可区分的公开响应。
练习¶
练习一:失败分类判断¶
AI 学习助手新增了以下场景,判断每个属于哪类失败,应映射为什么 HTTP 状态:
- 客户端发送
{"topic": 123, "weekly_hours": 6}—topic应为字符串 - 客户端发送
{"topic": "Python", "weekly_hours": 200}— 业务规定每周学习 上限为 40 小时 - 服务器的文件系统满了,无法写入计划数据
- 客户端发送
{"topic": "", "weekly_hours": 6}— 业务要求 topic 非空(假设 模型层未加min_length=1)
练习二:选择转换机制¶
以下场景中,你会选择 HTTPException 还是 exception handler?说明理由。
PlanCreationError只在POST /plans一个路由中出现InsufficientQuotaError在POST /plans、POST /goals和POST /sessions三个路由中都可能出现- 你希望所有路由的 404 错误都返回统一格式(包括
error_code和message)
练习三:设计错误结构¶
以下是两个路由返回的业务失败响应:
// POST /plans
{"detail": "weekly_hours must be positive"}
// POST /goals
{"error": "goal_invalid", "description": "deadline cannot be in the past"}
任务: 1. 指出这两个响应在结构上的不一致之处 2. 设计一个统一的错误结构(不超过 4 个字段),使两个路由的业务失败响应可以 共享同一个解析逻辑 3. 用你设计的结构重写这两个响应
练习四:修正 catch-all 掩盖错误的代码¶
以下 build_plan 的实现有问题:
def build_plan(goal: LearningGoal) -> Plan | None:
try:
if goal.weekly_hours <= 0:
return None
daily_minutes = goal.weekly_hours * 60 // 7
return Plan(
topic=goal.topic,
daily_minutes=daily_minutes,
steps=[f"每天学习 {goal.topic} {daily_minutes} 分钟"],
)
except Exception:
return None
任务: 1. 说明这段代码从"公开契约质量"角度有什么问题(提示:适配层能从返回值区分 "业务拒绝"和"代码 bug"吗?) 2. 重构代码,使其通过 return 表达成功、通过声明的异常表达业务失败、让未预期 异常自然冒泡
练习解析¶
练习一解析¶
| 场景 | 失败类型 | HTTP 状态 | 推理 |
|---|---|---|---|
1. topic: 123 |
校验失败 | 422 | 声明 str,实际收到 int,类型不匹配 |
2. weekly_hours: 200 |
业务失败 | 422 或 400 | 类型正确,但超出业务允许的范围;客户端应改小值 |
| 3. 文件系统满 | 未预期故障 | 500 | 客户端请求完全合法,是服务端资源问题 |
4. topic: "" |
业务失败 | 422 或 400 | 空字符串是合法 str(假设模型未限制),但业务不允许 |
关键推理:判断标准不是"错误发生在哪行代码",而是"客户端能不能通过改变自己的 行为来修复这个问题"。场景 1 和 2/4 都是客户端能修复的,但修复方式不同:1 是修 正类型,2/4 是修正值。场景 3 客户端什么都做不了。
注意场景 2 和 4 的状态码选择有设计空间——可以用 422(处理不了的实体)或 400 (坏请求),关键是客户端能通过状态码知道"这是我的问题,我应该改参数"。
练习二解析¶
-
PlanCreationError只在一个路由 → 用HTTPException。理由:错误只在 一处出现,就地 try-except 简单直接,不值得为一个路由注册全局 handler。 -
InsufficientQuotaError跨三个路由 → 用 exception handler。理由:如果 在三个路由里各写一次try...except InsufficientQuotaError,每次都要手动 保持相同的状态码和响应结构。一旦第四个路由加入,又要再抄一遍。exception handler 把转换逻辑集中在一处,所有路由自动享受一致的错误响应。 -
所有路由的 404 统一格式 → 用 exception handler。理由:这是全局行为—— 你希望无论哪个路由触发 404,客户端都收到相同结构的响应。注册一个 404 handler 即可覆盖所有路由,包括 FastAPI 自动返回的"路径不存在"404。
核心逻辑:错误出现范围越广、一致性要求越高 → 越适合 exception handler;错误
范围窄、逻辑简单 → HTTPException 足够。
练习三解析¶
- 不一致之处:
/plans用detail字段,/goals用error+description字段- 一个是纯文本消息,另一个区分了错误类型标识和描述
-
客户端需要两套不同的解析逻辑
-
统一结构设计:
class ErrorResponse(BaseModel):
error_code: str # 机器可读标识,用于程序化判断
message: str # 人类可读描述
field: str | None = None # 出错字段(如适用)
- 重写后的响应:
// POST /plans
{
"error_code": "invalid_plan_params",
"message": "weekly_hours must be positive",
"field": "weekly_hours"
}
// POST /goals
{
"error_code": "invalid_goal_params",
"message": "deadline cannot be in the past",
"field": "deadline"
}
客户端现在可以用一套逻辑处理所有业务失败:读 error_code 判断类型,读
message 展示给用户,读 field 定位表单中的具体输入框。
练习四解析¶
- 问题分析:
- 适配层只收到
None,无法知道失败原因是"业务拒绝"还是"代码 bug" except Exception捕获了所有异常——包括TypeError、MemoryError这些 本不该被静默处理的问题-
适配层无法给客户端正确的状态码:422(业务拒绝)和 500(内部故障)应该是 不同的响应,但
None无法区分 -
重构:
class PlanCreationError(Exception):
def __init__(self, reason: str, field: str | None = None):
self.reason = reason
self.field = field
def build_plan(goal: LearningGoal) -> Plan:
if goal.weekly_hours <= 0:
raise PlanCreationError(
reason="weekly_hours must be positive",
field="weekly_hours",
)
daily_minutes = goal.weekly_hours * 60 // 7
return Plan(
topic=goal.topic,
daily_minutes=daily_minutes,
steps=[f"每天学习 {goal.topic} {daily_minutes} 分钟"],
)
变化:
- 成功时 return Plan——类型明确,适配层可以直接映射为 201 + PlanResponse
- 业务失败时 raise PlanCreationError——适配层可以映射为 422 + 结构化错误
- 未预期异常(如 MemoryError)不被捕获,自然冒泡——适配层或全局 handler
将其映射为 500
三条路径各自分明,适配层可以为每条路径选择正确的 HTTP 响应。