跳转至

/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。

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

本章观察的 OpenAPI 生成行为在 FastAPI 0.115+ 各小版本中一致。

1. 打开 OpenAPI JSON,看看里面有什么

/docs 页面其实只是一个 UI 外壳。它背后的数据来源是一个结构化 JSON 文件—— 访问 GET /openapi.json 就能拿到。

启动 AI 学习助手后,请求这个端点:

curl http://localhost:8000/openapi.json | python -m json.tool

看到的是一个巨大的 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.schemaCreatePlanRequest
成功状态码和响应体 responses."201"PlanResponse
错误状态码和响应体 responses."422"HTTPValidationError

OpenAPI 用结构化 JSON 描述了 HTTP 接口的各个维度:路径、方法、请求体 schema、 响应 schema。客户端和工具(如代码生成器、测试框架、mock 服务)可以直接从这个 文件中定位调用所需的全部信息,而不需要阅读 Python 源码。

现在回答三个问题:

  1. 请求体的字段定义在哪里?——在 components/schemas/CreatePlanRequest 中, 包含 topic(string)和 weekly_hours(integer)
  2. 成功响应的字段定义在哪里?——在 components/schemas/PlanResponse 中, 包含 topicdaily_minutessteps
  3. 有没有描述业务错误响应?

第三个问题的答案是:没有

2. 找到了成功和校验错误,但业务错误去哪了?

仔细看 responses 里只有两个 key:"201""422"

"201" 是成功响应。"422" 呢?看它的 schema 引用—— HTTPValidationError。这是 FastAPI 内置的 Pydantic 校验错误格式,结构是:

{
  "detail": [
    {"type": "missing", "loc": ["body", "topic"], "msg": "Field required"}
  ]
}

这对应的是 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_codemessagefield), 跟 HTTPValidationError(包含 detail 数组)完全不同。但 OpenAPI JSON 中 根本没有描述它的存在。

如果你是这个 API 的客户端开发者,只看 OpenAPI 文档,你能知道 weekly_hours=0 会返回什么错误吗?不能。你只知道可能收到 201(成功)或 422(校验错误,格式是 detail 数组)。当实际收到一个不认识的 JSON 结构时,你的代码会懵。

为什么会这样?因为 FastAPI 自动生成 OpenAPI 时,只做了两件事:

  1. status_code=201response_model=PlanResponse 生成成功响应描述
  2. 自动添加一个默认的 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.jsonPOST /plansresponses 现在变了:

{
  "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_codemessagefield 字段。他可以提前编写对应的错误处理代码,而不是在运行时遇到意外结构 才手忙脚乱。

如果 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 类型:

interface ErrorResponse {
  error_code: string;
  message: string;
  field?: string;
}

运行时收到的实际响应多了一个 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——客户端的条件分支永远走不进去。

误区四:用 ResponseJSONResponse 直接返回绕过 response_model 校验

FastAPI 的 response_model 在序列化阶段过滤字段,确保实际响应符合声明。但 如果你用 JSONResponse(content=...) 绕过了它,框架不会校验你返回的内容是否 匹配 OpenAPI 声明。文档说响应包含 topicdaily_minutessteps,但实际 返回了任意 JSON——没人阻止你,只有客户端在运行时发现不对劲。

最小反例:某个条件分支为了"方便"直接 return JSONResponse(content={"ok": True})。 OpenAPI 声称 201 响应是 PlanResponse,但这个分支返回的是 {"ok": true}。 客户端按 PlanResponse 解析,取 topic 字段得到 undefined——页面显示空白。

本章小结

OpenAPI 用结构化 JSON 描述 HTTP 接口的路径、方法、请求体、响应体和错误—— 使接口可被人和工具发现,而不需要阅读源码或反复试错。

FastAPI 从 path operation 的签名和配置自动生成 OpenAPI,但只包含显式声明的 内容:成功响应来自 response_modelstatus_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 作为"可验证契约"而非"自动装饰"的核心区别

练习解析

练习一解析

  1. 公开契约维度:
维度 内容
路径 /plans/{plan_id}
方法 DELETE
参数 plan_id,来源为 path,类型 string,必填
成功状态码 204 No Content(无响应体)
错误状态码 404,响应体为 ErrorResponse
  1. 如果计划存在,客户端收到 HTTP 204 No Content——没有响应体。204 表示 "操作成功,没有需要返回的内容"。

  2. 客户端开发者看文档只知道可能收到 204(成功)和 404(不存在)。他不知道还 可能收到 409——当尝试删除 7 天内创建的计划时,服务返回了一个文档从未提及 的状态码。客户端的错误处理代码没有 409 分支,这个响应会落入通用的 "unknown error" 处理,用户看到的错误信息毫无帮助。

练习二解析

  1. OpenAPI 缺少 409 状态码的描述。responses 只声明了 422,但 handle_conflict handler 会返回 409。

  2. 当触发 PlanConflictError 时,客户端收到 HTTP 409。但按 OpenAPI 文档, 这个接口只会返回 201 和 422——客户端的错误处理逻辑中没有 409 分支。 响应会被当作"未知错误"处理,或者某些严格的客户端 SDK 可能直接报告 "received undocumented status code"。

  3. 补全后的 responses

responses={
    422: {
        "description": "计划参数无效",
        "model": ErrorResponse,
    },
    409: {
        "description": "计划已存在,资源冲突",
        "model": ErrorResponse,
    },
}

练习三解析

  1. 偏差在 request_id 字段:实际响应包含它,但 OpenAPI schema 中没有声明。

  2. 偏差。OpenAPI schema 声明了 ErrorResponse 只有 error_codemessagefield 三个属性。实际响应多了一个 request_id,超出了 schema 的描述范围。对于使用严格解析的客户端(如某些代码生成器产出的类型), 未声明的字段可能导致解析错误或被静默丢弃——无论哪种,都意味着文档不再是 对实际行为的准确描述。

  3. 两种选择:

  4. request_id 成为契约:在 ErrorResponse 模型中添加 request_id: str 字段。更新后 OpenAPI schema 会包含这个字段,客户端 可以依赖它存在。

  5. 不让它出现在响应中:从 exception handler 的返回内容中移除 request_id——如果客户端不需要它,就不应该出现在公开响应里。 request_id 可以只写入服务端日志用于内部排查。

练习四解析

  1. 团队 A 的客户端开发者最可能遇到的问题:运行时出现文档未描述的错误响应。 由于从不配置 responses,所有业务错误(409、403 等)和自定义错误结构在 OpenAPI 中都不存在。客户端开发者只能通过"试一试看返回什么"来了解接口行为 ——这既低效又不可靠,因为某些错误只在特定条件下触发。

  2. 团队 B 的做法减少运行时意外的原因:客户端团队从 OpenAPI 自动生成类型定义, 这些类型覆盖了所有已知的状态码和响应结构。当运行时收到一个响应时,它必然 落在某个已生成的类型分支中——不会出现"从未见过的响应格式"的情况。同步更新 确保类型定义始终反映最新的接口行为。

  3. 核心区别:"可验证契约"意味着你主动确保 OpenAPI 覆盖所有已知行为并与实际 响应一致;"自动装饰"意味着你把生成结果当作副产品,不检查它是否准确。

参考资料