跳转至

全部返回 400,客户端到底该改什么?

上一章结束时,我们已经知道 Pydantic 校验失败会产生结构化的 ValidationError, 精确告诉客户端"哪个字段、什么类型、因为什么原因被拒绝"。FastAPI 把它变成 HTTP 422 响应,一切看起来井然有序。

但这只是失败世界的冰山一角。

AI 学习助手的 build_plan 在真实运行中不只会遇到"字段类型错误"这一种失败。 有时客户端发了一个类型完全正确但业务上荒谬的请求(weekly_hours=0),有时 数据库连接突然断了。如果你把所有这些失败都塞进同一个 HTTP 400 和同一条 "Error" 消息里,客户端收到错误后会面临一个致命困境:我到底该修正输入、 改变请求参数,还是等服务恢复?

这一章就从这个困境开始。

运行环境

Python 3.12、FastAPI 0.115+、Pydantic v2。

python -m pip install "fastapi[standard]>=0.115" "pydantic>=2,<3"

1. 三次失败,同一个 400

假设客户端连续调用三次 POST /plans,每次因为不同原因失败:

第一次——字段类型错误:

{"topic": "Python typing", "weekly_hours": "很多"}

第二次——类型正确但业务不允许:

{"topic": "Python typing", "weekly_hours": 0}

第三次——请求本身完全合法,但服务器内存溢出导致计算无法完成:

{"topic": "Python typing", "weekly_hours": 6}

如果服务端对这三种情况全部返回:

HTTP/1.1 400 Bad Request

{"status": 400, "message": "Error"}

客户端开发者看到三次一模一样的 400 响应。先停下来想想:面对这三次失败, 客户端分别应该做什么?

  • 第一次:输入格式有误 → 客户端应该修正 weekly_hours 的类型
  • 第二次:格式正确但值不合理 → 客户端应该改变业务参数(让用户填写大于 0 的值)
  • 第三次:客户端什么都没做错 → 应该等一等再重试

三种完全不同的下一步动作,但客户端从响应中完全无法区分应该走哪条路。它只能 猜。猜错了代价是什么?用户看到"请修正输入"的提示,但实际上是服务端宕机了—— 用户反复修改表单也无济于事,只会越来越沮丧。

混淆归责的本质伤害:客户端无法判断失败属于谁的责任,也就无法选择正确的 恢复策略。错误分类的目标不是给状态码"分配名字",而是让客户端仅通过状态码和 响应体就能决定下一步动作。

2. 三类失败:用"客户端该做什么"来分类

既然问题的核心是"客户端不知道下一步该做什么",那分类的依据就应该是:这个 失败是谁的责任,客户端能修复吗?

把三次失败重新归类:

失败来源 归责 HTTP 状态 客户端应做什么 示例
校验失败 客户端输入格式错误 422 修正输入类型 "很多" 无法转为 int
业务失败 客户端请求合法但不符业务前提 4xx(如 409/422) 改变业务参数 weekly_hours=0 不符业务规则
未预期故障 服务端自身问题 5xx 等待服务恢复 内存溢出

分类的判据是一棵简单的决策树:

  1. 失败的来源在哪里?
  2. 客户端发送的数据本身不满足声明的类型约束 → 校验失败
  3. 数据类型正确,但不满足业务前提条件 → 业务失败
  4. 客户端的请求完全合法,是服务端自身出了问题 → 未预期故障

  5. 对应的 HTTP 状态族:

  6. 4xx 表示"客户端可修正的问题"——请求本身有问题
  7. 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 /goalsPUT /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

从公开契约角度看,这有什么问题?

  1. 客户端收到 null 后无法知道失败原因——和全部返回 400 的困境一样
  2. try...except Exception所有错误(包括编程 bug)都掩盖了——本该 暴露的内存溢出或类型错误被悄悄吞掉
  3. build_plan 的调用方(适配层)无法区分"业务拒绝"和"代码 bug",因此也 无法给客户端正确的 HTTP 状态码

正确的做法是:函数要么返回声明的成功结果,要么 raise 声明的异常——不通过 catch-all 或返回无声明形状来掩盖错误。

这个原则不只是代码风格偏好。它直接影响公开契约的质量:只有当内部函数诚实地 暴露不同类型的失败时,适配层才能把它们映射为客户端可区分的 HTTP 响应。

那么错误结构设计完成后,还需要做什么才能让客户端在调用前就知道可能的错误? 答案是:已知错误必须进入接口描述(OpenAPI)。客户端不应该在运行时"遇到"错误 才知道其存在——它应该在阅读文档时就能看到所有可能的响应状态和结构。这是 P05 将展开的话题。

边界与常见误区

误区一:用 HTTP 200 包裹所有结果

HTTP/1.1 200 OK
{"success": false, "error": "weekly_hours must be positive"}

这种设计让状态码失去意义。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 校验失败和业务拒绝用同一个状态码和结构返回

# 校验失败和业务失败都返回完全相同的格式
raise HTTPException(status_code=400, detail="Invalid input")

客户端收到 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 状态:

  1. 客户端发送 {"topic": 123, "weekly_hours": 6}topic 应为字符串
  2. 客户端发送 {"topic": "Python", "weekly_hours": 200} — 业务规定每周学习 上限为 40 小时
  3. 服务器的文件系统满了,无法写入计划数据
  4. 客户端发送 {"topic": "", "weekly_hours": 6} — 业务要求 topic 非空(假设 模型层未加 min_length=1

练习二:选择转换机制

以下场景中,你会选择 HTTPException 还是 exception handler?说明理由。

  1. PlanCreationError 只在 POST /plans 一个路由中出现
  2. InsufficientQuotaErrorPOST /plansPOST /goalsPOST /sessions 三个路由中都可能出现
  3. 你希望所有路由的 404 错误都返回统一格式(包括 error_codemessage

练习三:设计错误结构

以下是两个路由返回的业务失败响应:

// 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 (坏请求),关键是客户端能通过状态码知道"这是我的问题,我应该改参数"。

练习二解析

  1. PlanCreationError 只在一个路由 → 用 HTTPException。理由:错误只在 一处出现,就地 try-except 简单直接,不值得为一个路由注册全局 handler。

  2. InsufficientQuotaError 跨三个路由 → 用 exception handler。理由:如果 在三个路由里各写一次 try...except InsufficientQuotaError,每次都要手动 保持相同的状态码和响应结构。一旦第四个路由加入,又要再抄一遍。exception handler 把转换逻辑集中在一处,所有路由自动享受一致的错误响应。

  3. 所有路由的 404 统一格式 → 用 exception handler。理由:这是全局行为—— 你希望无论哪个路由触发 404,客户端都收到相同结构的响应。注册一个 404 handler 即可覆盖所有路由,包括 FastAPI 自动返回的"路径不存在"404。

核心逻辑:错误出现范围越广、一致性要求越高 → 越适合 exception handler;错误 范围窄、逻辑简单 → HTTPException 足够。

练习三解析

  1. 不一致之处:
  2. /plansdetail 字段,/goalserror + description 字段
  3. 一个是纯文本消息,另一个区分了错误类型标识和描述
  4. 客户端需要两套不同的解析逻辑

  5. 统一结构设计:

class ErrorResponse(BaseModel):
    error_code: str           # 机器可读标识,用于程序化判断
    message: str              # 人类可读描述
    field: str | None = None  # 出错字段(如适用)
  1. 重写后的响应:
// 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 定位表单中的具体输入框。

练习四解析

  1. 问题分析:
  2. 适配层只收到 None,无法知道失败原因是"业务拒绝"还是"代码 bug"
  3. except Exception 捕获了所有异常——包括 TypeErrorMemoryError 这些 本不该被静默处理的问题
  4. 适配层无法给客户端正确的状态码:422(业务拒绝)和 500(内部故障)应该是 不同的响应,但 None 无法区分

  5. 重构:

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 响应。

参考资料