/docs 页面漂亮极了,但客户端踩了文档没提的坑¶
上一章结束时,AI 学习助手的 POST /plans 已经有了三条清晰的路径:成功返回
201 + PlanResponse,校验失败返回 422 + 结构化校验错误,业务失败返回 422 +
ErrorResponse。错误结构设计完了,exception handler 也注册了。
你打开 http://localhost:8000/docs,Swagger UI 展示了一个漂亮的交互文档:
可以看到 POST /plans 的请求体、响应体,甚至能直接在页面上试一试。看起来
一切就绪——客户端开发者只要照着这个文档写代码就行了。
但等一下。客户端开发者看着文档,发现成功响应(201)和校验错误(422)的描述
都很完整。他写好了这两种情况的处理代码,信心满满地上线了。然后第一个用户
输入了 weekly_hours=0——服务返回了一个 422 响应,body 里是
{"error_code": "invalid_plan_params", "message": "weekly_hours must be positive", "field": "weekly_hours"}。
客户端崩了。不是因为服务端出了 bug,而是因为客户端从来不知道会有这种格式的
错误响应。文档里没有提到它。客户端的错误处理代码只认识 Pydantic 校验错误的
detail 数组格式——面对一个从未见过的 JSON 结构,解析逻辑直接抛异常。
这就是本章的问题:为什么 /docs 页面"看起来完整"不等于"接口描述真的完整"?
运行环境¶
Python 3.12、FastAPI 0.115+、Pydantic v2、OpenAPI 3.1。
本章观察的 OpenAPI 生成行为在 FastAPI 0.115+ 各小版本中一致。
1. 打开 OpenAPI JSON,看看里面有什么¶
/docs 页面其实只是一个 UI 外壳。它背后的数据来源是一个结构化 JSON 文件——
访问 GET /openapi.json 就能拿到。
启动 AI 学习助手后,请求这个端点:
看到的是一个巨大的 JSON 对象。我们只关注 POST /plans 对应的部分:
{
"paths": {
"/plans": {
"post": {
"summary": "Create Plan",
"operationId": "create_plan_plans_post",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePlanRequest"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlanResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
}
}
}
先把这个结构跟 P01 建立的公开契约维度对照一下:
| P01 建立的公开契约维度 | OpenAPI 中的对应位置 |
|---|---|
路径(/plans) |
paths 的 key |
| HTTP 方法(POST) | paths."/plans".post |
| 请求体结构 | requestBody.content.schema → CreatePlanRequest |
| 成功状态码和响应体 | responses."201" → PlanResponse |
| 错误状态码和响应体 | responses."422" → HTTPValidationError |
OpenAPI 用结构化 JSON 描述了 HTTP 接口的各个维度:路径、方法、请求体 schema、 响应 schema。客户端和工具(如代码生成器、测试框架、mock 服务)可以直接从这个 文件中定位调用所需的全部信息,而不需要阅读 Python 源码。
现在回答三个问题:
- 请求体的字段定义在哪里?——在
components/schemas/CreatePlanRequest中, 包含topic(string)和weekly_hours(integer) - 成功响应的字段定义在哪里?——在
components/schemas/PlanResponse中, 包含topic、daily_minutes、steps - 有没有描述业务错误响应?
第三个问题的答案是:没有。
2. 找到了成功和校验错误,但业务错误去哪了?¶
仔细看 responses 里只有两个 key:"201" 和 "422"。
"201" 是成功响应。"422" 呢?看它的 schema 引用——
HTTPValidationError。这是 FastAPI 内置的 Pydantic 校验错误格式,结构是:
这对应的是 P02 讨论过的 ValidationError 转 HTTP 响应的情况:客户端发了
类型不对或缺失字段的请求。
但回忆一下 P03 设计的业务错误——当 weekly_hours=0 时,服务返回的是:
HTTP/1.1 422 Unprocessable Entity
{
"error_code": "invalid_plan_params",
"message": "weekly_hours must be positive",
"field": "weekly_hours"
}
这个响应的结构是 ErrorResponse(包含 error_code、message、field),
跟 HTTPValidationError(包含 detail 数组)完全不同。但 OpenAPI JSON 中
根本没有描述它的存在。
如果你是这个 API 的客户端开发者,只看 OpenAPI 文档,你能知道 weekly_hours=0
会返回什么错误吗?不能。你只知道可能收到 201(成功)或 422(校验错误,格式是
detail 数组)。当实际收到一个不认识的 JSON 结构时,你的代码会懵。
为什么会这样?因为 FastAPI 自动生成 OpenAPI 时,只做了两件事:
- 从
status_code=201和response_model=PlanResponse生成成功响应描述 - 自动添加一个默认的 422 条目,表示 Pydantic 校验可能失败
至于你在 exception handler 里返回的业务错误——FastAPI 不知道它们的存在。 exception handler 是运行时才触发的代码,框架在生成 OpenAPI 的时候不会去分析 哪些异常可能被抛出、handler 会返回什么结构。
核心发现:FastAPI 自动生成的 OpenAPI 只包含你在 path operation 装饰器中 显式声明的响应信息。已知的业务错误如果不主动配置,就不会出现在文档中。
3. 把已知错误补充到 OpenAPI 中¶
知道了缺失,下一步是修复它。FastAPI 的 path operation 装饰器提供了 responses
参数,允许你声明额外的响应状态码和 schema:
# schemas.py
from pydantic import BaseModel
class ErrorResponse(BaseModel):
error_code: str
message: str
field: str | None = None
# router.py
from fastapi import APIRouter
from schemas import CreatePlanRequest, PlanResponse, ErrorResponse
router = APIRouter()
@router.post(
"/plans",
status_code=201,
response_model=PlanResponse,
responses={
422: {
"description": "请求参数无效(校验错误或业务规则拒绝)",
"model": ErrorResponse,
},
},
)
async def create_plan(request: CreatePlanRequest):
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
return build_plan(goal)
重新启动服务,再次访问 GET /openapi.json。POST /plans 的 responses 现在变了:
{
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/PlanResponse"}
}
}
},
"422": {
"description": "请求参数无效(校验错误或业务规则拒绝)",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
}
}
}
ErrorResponse 的 schema 也出现在了 components/schemas 中:
{
"ErrorResponse": {
"type": "object",
"required": ["error_code", "message"],
"properties": {
"error_code": {"type": "string"},
"message": {"type": "string"},
"field": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": null
}
}
}
}
客户端开发者现在可以从文档中看到:422 响应可能包含 error_code、message 和
field 字段。他可以提前编写对应的错误处理代码,而不是在运行时遇到意外结构
才手忙脚乱。
如果 P03 中设计了多种不同状态码的业务错误(比如 409 Conflict 表示资源冲突), 它们应该分开声明:
@router.post(
"/plans",
status_code=201,
response_model=PlanResponse,
responses={
422: {
"description": "计划参数无效",
"model": ErrorResponse,
},
409: {
"description": "计划已存在,资源冲突",
"model": ErrorResponse,
},
},
)
每个状态码对应不同的客户端行为:422 意味着"改参数再试",409 意味着"资源已 存在,换个名字或先删除旧的"。如果把它们合并成一个条目,客户端就又回到了 "收到错误不知道该做什么"的老问题。
这里有一个判断标准:如果客户端收到两种错误后应该执行不同的恢复动作,它们 就应该用不同的状态码声明在 OpenAPI 中。
4. 声明了不等于一致——如何发现偏差¶
补充完声明后,文档是完整了。但文档正确吗?
来看一个具体场景。你修改了 ErrorResponse,添加了一个 timestamp 字段:
class ErrorResponse(BaseModel):
error_code: str
message: str
field: str | None = None
timestamp: str # 新增:错误发生时间
exception handler 也更新了,实际响应现在包含 timestamp:
HTTP/1.1 422 Unprocessable Entity
{
"error_code": "invalid_plan_params",
"message": "weekly_hours must be positive",
"field": "weekly_hours",
"timestamp": "2026-06-22T10:30:00Z"
}
但你忘了重新启动服务(或者用的是旧的 responses 配置里引用了旧版
ErrorResponse)。OpenAPI JSON 中的 ErrorResponse schema 仍然只有三个
字段——没有 timestamp。
客户端开发者看文档生成了 TypeScript 类型:
运行时收到的实际响应多了一个 timestamp 字段。在强类型语言中,这个未声明的
字段可能被忽略(如果客户端用了宽松解析),也可能触发严格校验错误(如果客户端
期望响应严格匹配 schema)。
这就是契约偏差:实际 HTTP 响应与 OpenAPI 描述不一致。
定位偏差的方法是逐项对比:
| 核对维度 | OpenAPI 描述 | 实际 HTTP 响应 | 一致? |
|---|---|---|---|
| 状态码 | 422 | 422 | 是 |
| Content-Type | application/json | application/json | 是 |
| 字段:error_code | string,必填 | "invalid_plan_params" |
是 |
| 字段:message | string,必填 | "weekly_hours must be positive" |
是 |
| 字段:field | string,可选 | "weekly_hours" |
是 |
| 字段:timestamp | 未声明 | "2026-06-22T10:30:00Z" |
否 |
发现偏差后,应该修改实现还是修改文档?判断标准是:这个字段是否应该成为公开 契约的一部分?
- 如果
timestamp确实是你希望客户端能依赖的信息 → 更新 OpenAPI 声明,让 文档反映真实行为 - 如果
timestamp是调试时临时加的、不打算让客户端依赖 → 从实现中移除它, 保持响应与文档一致
偏差的另一个来源更隐蔽:使用 Response 直接返回绕过了 response_model
校验。
from fastapi.responses import JSONResponse
@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest):
plan = build_plan(goal)
# 绕过 response_model,直接返回任意 JSON
return JSONResponse(content={"surprise": "field"}, status_code=201)
OpenAPI 声称 201 响应包含 PlanResponse 的三个字段,但实际返回了一个
{"surprise": "field"}。response_model 的过滤从未执行——因为你用
JSONResponse 绕过了它。文档撒了谎,而 FastAPI 不会阻止你这么做。
契约一致性要求:客户端实际收到的状态码、字段和结构应与 OpenAPI 描述逐项 对应。任何偏差——无论是多了字段、少了字段还是类型不匹配——都意味着文档不再 可信。
5. OpenAPI 是可验证的公开契约,不是自动生成的装饰¶
回顾一下我们走过的路:
P01:设计了公开契约(路径、方法、请求体、响应体)
P03:设计了错误结构(ErrorResponse,业务失败和校验失败分开)
P05:将它们描述在 OpenAPI 中,并核对实际行为是否与描述一致
如果没有 OpenAPI,客户端开发者了解接口的唯一方式是:
- 阅读你的 Python 源码(假设他有权限)
- 反复试错,通过实际请求摸索可能的响应
- 依赖口头约定或过时的 Confluence 文档
这些方式都不可靠,而且无法被工具自动消费。OpenAPI 不只是 /docs 页面背后的
数据——它是一个结构化的、可被程序解析的契约描述。代码生成器可以据此生成客户端
SDK,测试工具可以据此验证响应是否符合声明,mock 服务可以据此返回模拟数据。
把 OpenAPI 当作"自动生成的附属装饰"意味着:反正 FastAPI 会自动生成,我不用管它。 结果就是文档不完整(缺少业务错误)或不准确(与实际行为有偏差),客户端依赖 它就会踩坑。
把 OpenAPI 当作"可验证的公开契约"意味着:我主动确保文档描述了接口的所有已知 行为——成功路径、已知错误、参数规则。任何运行时行为变更都应同步反映在 OpenAPI 中。文档和实现是同一个承诺的两种表达形式。
边界与常见误区¶
误区一:认为能打开 /docs 页面就表示接口契约正确
/docs 页面只是 OpenAPI JSON 的可视化。如果 JSON 本身缺少业务错误描述,
/docs 也不会显示它们。"页面能打开"只证明 FastAPI 成功生成了 OpenAPI,不
证明生成的内容覆盖了所有已知行为。
最小反例:一个路由没有配置 responses 参数,/docs 页面只展示 201 和默认
422。客户端开发者照着文档实现,上线后遇到了从未听说过的 409 Conflict——因为
你的 exception handler 会返回它,但 OpenAPI 里没有声明。
误区二:只描述成功响应,不把已知 4xx 错误的 schema 纳入 OpenAPI
成功路径是接口的"阳光场景",但客户端需要处理的大部分代码其实在错误分支里。 如果文档只告诉客户端"一切顺利时你会收到什么",却不说"出错时你会收到什么", 客户端的错误处理代码就只能靠猜。
最小反例:客户端用 try { parse as PlanResponse } catch { show "Unknown error" }
处理所有非 201 响应。当业务错误返回了一个包含有用 field 信息的结构化 JSON
时,客户端看都没看就把它丢进了通用错误分支。用户看到"未知错误"而不是"请填写
大于 0 的学习时长"。
误区三:修改了运行时行为但忘记同步 responses 声明
这是偏差产生的最常见原因。你给 ErrorResponse 加了字段、改了状态码、或者在
新的 exception handler 里返回了新结构——但 responses 参数还是老的配置。
代码改了,文档没跟上。
最小反例:团队决定把业务错误从 422 改为 409。exception handler 更新了,
但 responses={422: ...} 没改。客户端还在监听 422 做错误处理,实际收到的
却是 409——客户端的条件分支永远走不进去。
误区四:用 Response 或 JSONResponse 直接返回绕过 response_model 校验
FastAPI 的 response_model 在序列化阶段过滤字段,确保实际响应符合声明。但
如果你用 JSONResponse(content=...) 绕过了它,框架不会校验你返回的内容是否
匹配 OpenAPI 声明。文档说响应包含 topic、daily_minutes、steps,但实际
返回了任意 JSON——没人阻止你,只有客户端在运行时发现不对劲。
最小反例:某个条件分支为了"方便"直接 return JSONResponse(content={"ok": True})。
OpenAPI 声称 201 响应是 PlanResponse,但这个分支返回的是 {"ok": true}。
客户端按 PlanResponse 解析,取 topic 字段得到 undefined——页面显示空白。
本章小结¶
OpenAPI 用结构化 JSON 描述 HTTP 接口的路径、方法、请求体、响应体和错误—— 使接口可被人和工具发现,而不需要阅读源码或反复试错。
FastAPI 从 path operation 的签名和配置自动生成 OpenAPI,但只包含显式声明的
内容:成功响应来自 response_model 和 status_code,默认 422 来自 Pydantic
校验。已知的业务错误默认不会出现——需要通过 responses 参数主动声明。
契约一致性要求实际 HTTP 响应与 OpenAPI 描述逐项对应。偏差可能来自:修改了
实现但忘记更新声明,或绕过了 response_model 直接返回任意内容。定位偏差的
方法是逐项比较:状态码、Content-Type、响应字段的名称、类型和必填/可选性。
OpenAPI 不是 /docs 页面的附属装饰,而是公开契约的可验证形式。把它当作
契约意味着:任何运行时行为变更都应同步反映在文档中,文档覆盖的不只是阳光
路径,还包括所有客户端需要适配的已知错误。
练习¶
练习一:在 OpenAPI JSON 中定位契约维度¶
以下是 AI 学习助手新增的 DELETE /plans/{plan_id} 接口的 OpenAPI 片段:
{
"paths": {
"/plans/{plan_id}": {
"delete": {
"parameters": [
{"name": "plan_id", "in": "path", "required": true, "schema": {"type": "string"}}
],
"responses": {
"204": {"description": "No Content"},
"404": {
"description": "计划不存在",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
}
}
}
}
}
}
任务:
1. 列出这个接口的公开契约维度(路径、方法、参数来源和类型、成功状态码、
错误状态码和结构)
2. 如果客户端发送 DELETE /plans/abc123 且计划存在,它会收到什么响应?
3. 如果你为这个接口添加了业务规则"不能删除 7 天内创建的计划",返回 409,
但没有更新 responses——客户端开发者看文档会遗漏什么?
练习二:判断 OpenAPI 缺失了什么¶
以下是 POST /plans 当前的 responses 配置和实际的 exception handler:
# router.py
@router.post(
"/plans",
status_code=201,
response_model=PlanResponse,
responses={
422: {"description": "参数无效", "model": ErrorResponse},
},
)
# error_handlers.py
@app.exception_handler(PlanCreationError)
async def handle_plan_error(request, exc):
return JSONResponse(status_code=422, content={...})
@app.exception_handler(PlanConflictError)
async def handle_conflict(request, exc):
return JSONResponse(status_code=409, content={...})
任务:
1. 对比 responses 声明和 exception handler 的实际行为——OpenAPI 缺少了
哪个状态码的描述?
2. 客户端如果严格按照 OpenAPI 文档实现错误处理,会在什么情况下遇到意外?
3. 写出补全后的 responses 参数
练习三:定位契约偏差¶
以下是 POST /plans 的 OpenAPI 中 ErrorResponse 的 schema:
{
"ErrorResponse": {
"type": "object",
"required": ["error_code", "message"],
"properties": {
"error_code": {"type": "string"},
"message": {"type": "string"},
"field": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": null
}
}
}
}
实际 HTTP 响应:
{
"error_code": "invalid_plan_params",
"message": "weekly_hours must be positive",
"field": "weekly_hours",
"request_id": "req-abc-123"
}
任务:
1. 逐项对比,指出哪个字段存在偏差
2. 这算偏差吗?为什么?
3. 如果你决定 request_id 应该成为公开契约的一部分,需要做什么修改?
如果决定它不应该出现在响应中,需要做什么修改?
练习四:判断 OpenAPI 的角色¶
以下两个团队对 OpenAPI 有不同的态度:
团队 A:"OpenAPI 是 FastAPI 自动生成的,我们从来不手动配置 responses。
反正 /docs 页面总是有的,客户端开发者可以直接试 API 看返回什么。"
团队 B:"每次修改路由的错误处理逻辑后,我们都会检查 responses 是否需要
同步更新。客户端团队用我们的 OpenAPI 自动生成 TypeScript 类型。"
任务: 1. 团队 A 的客户端开发者在集成时最可能遇到什么问题? 2. 团队 B 的做法为什么能减少客户端的运行时意外? 3. 用一句话概括 OpenAPI 作为"可验证契约"而非"自动装饰"的核心区别
练习解析¶
练习一解析¶
- 公开契约维度:
| 维度 | 内容 |
|---|---|
| 路径 | /plans/{plan_id} |
| 方法 | DELETE |
| 参数 | plan_id,来源为 path,类型 string,必填 |
| 成功状态码 | 204 No Content(无响应体) |
| 错误状态码 | 404,响应体为 ErrorResponse |
-
如果计划存在,客户端收到
HTTP 204 No Content——没有响应体。204 表示 "操作成功,没有需要返回的内容"。 -
客户端开发者看文档只知道可能收到 204(成功)和 404(不存在)。他不知道还 可能收到 409——当尝试删除 7 天内创建的计划时,服务返回了一个文档从未提及 的状态码。客户端的错误处理代码没有 409 分支,这个响应会落入通用的 "unknown error" 处理,用户看到的错误信息毫无帮助。
练习二解析¶
-
OpenAPI 缺少 409 状态码的描述。
responses只声明了 422,但handle_conflicthandler 会返回 409。 -
当触发
PlanConflictError时,客户端收到 HTTP 409。但按 OpenAPI 文档, 这个接口只会返回 201 和 422——客户端的错误处理逻辑中没有 409 分支。 响应会被当作"未知错误"处理,或者某些严格的客户端 SDK 可能直接报告 "received undocumented status code"。 -
补全后的
responses:
responses={
422: {
"description": "计划参数无效",
"model": ErrorResponse,
},
409: {
"description": "计划已存在,资源冲突",
"model": ErrorResponse,
},
}
练习三解析¶
-
偏差在
request_id字段:实际响应包含它,但 OpenAPI schema 中没有声明。 -
这算偏差。OpenAPI schema 声明了
ErrorResponse只有error_code、message和field三个属性。实际响应多了一个request_id,超出了 schema 的描述范围。对于使用严格解析的客户端(如某些代码生成器产出的类型), 未声明的字段可能导致解析错误或被静默丢弃——无论哪种,都意味着文档不再是 对实际行为的准确描述。 -
两种选择:
-
让
request_id成为契约:在ErrorResponse模型中添加request_id: str字段。更新后 OpenAPI schema 会包含这个字段,客户端 可以依赖它存在。 - 不让它出现在响应中:从 exception handler 的返回内容中移除
request_id——如果客户端不需要它,就不应该出现在公开响应里。request_id可以只写入服务端日志用于内部排查。
练习四解析¶
-
团队 A 的客户端开发者最可能遇到的问题:运行时出现文档未描述的错误响应。 由于从不配置
responses,所有业务错误(409、403 等)和自定义错误结构在 OpenAPI 中都不存在。客户端开发者只能通过"试一试看返回什么"来了解接口行为 ——这既低效又不可靠,因为某些错误只在特定条件下触发。 -
团队 B 的做法减少运行时意外的原因:客户端团队从 OpenAPI 自动生成类型定义, 这些类型覆盖了所有已知的状态码和响应结构。当运行时收到一个响应时,它必然 落在某个已生成的类型分支中——不会出现"从未见过的响应格式"的情况。同步更新 确保类型定义始终反映最新的接口行为。
-
核心区别:"可验证契约"意味着你主动确保 OpenAPI 覆盖所有已知行为并与实际 响应一致;"自动装饰"意味着你把生成结果当作副产品,不检查它是否准确。