功能全写完了,但一个请求跑不通?¶
AI 学习助手的代码已经全部到位:APIRouter 声明了 POST /plans,
CreatePlanRequest 约束了输入,PlanResponse 过滤了响应,ErrorResponse
定义了错误结构,lifespan 把启动和清理配对好了,OpenAPI 里也补上了已知错误。
每个文件单独看都挺好。
然后你执行 uvicorn main:app,发一个请求——404 Not Found。
路由写了,服务也启动了,为什么 404?你检查了半天,发现 app.include_router(router)
那一行被注释掉了。路由没有注册到应用实例上。
这不是功能缺失——每个"零件"都存在,只是它们没有组装在一起。单独检查每个文件 都没问题,问题出在组件之间的配合关系上。这一章就从一个完整请求出发,沿着数据 流逐步核对:从客户端发出请求到收到响应,每个环节的设计是否彼此一致?
运行环境¶
Python 3.12、FastAPI 0.115+、Pydantic v2、uvicorn。
1. 追踪一个请求的完整数据流¶
先把 AI 学习助手的完整最小服务放在一起看。不是为了重复前几章的代码,而是为了 追踪一个请求从进入到离开经过的每一步:
# schemas.py
from pydantic import BaseModel, Field
class CreatePlanRequest(BaseModel):
topic: str = Field(min_length=1)
weekly_hours: int = Field(gt=0)
class PlanResponse(BaseModel):
topic: str
daily_minutes: int
steps: list[str]
class ErrorResponse(BaseModel):
error_code: str
message: str
field: str | None = None
# core.py
from dataclasses import dataclass
@dataclass(frozen=True)
class LearningGoal:
topic: str
weekly_hours: int
@dataclass(frozen=True)
class Plan:
topic: str
daily_minutes: int
steps: list[str]
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 > 40:
raise PlanCreationError(
reason="weekly_hours exceeds maximum of 40",
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} 分钟"],
)
# router.py
from fastapi import APIRouter
from core import build_plan, LearningGoal, PlanCreationError
from schemas import CreatePlanRequest, PlanResponse, ErrorResponse
router = APIRouter()
@router.post(
"/plans",
status_code=201,
response_model=PlanResponse,
responses={422: {"model": ErrorResponse}},
)
async def create_plan(request: CreatePlanRequest):
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
return build_plan(goal)
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from router import router
from error_handlers import register_error_handlers
import logging
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("service ready")
yield
logger.info("service stopped")
app = FastAPI(lifespan=lifespan)
register_error_handlers(app)
app.include_router(router)
现在追踪一个成功请求:
客户端发送:POST /plans {"topic": "Python", "weekly_hours": 12}
│
▼ ① HTTP 请求到达 uvicorn
│
▼ ② FastAPI 路由匹配:POST /plans → create_plan(P01 的路由契约设计)
│
▼ ③ 参数绑定:请求体 JSON → CreatePlanRequest(P01 的参数来源声明)
│
▼ ④ Pydantic 校验:topic 非空?weekly_hours > 0?(P02 的运行时校验边界)
│
▼ ⑤ 适配层转换:CreatePlanRequest → LearningGoal(P01 的依赖方向)
│
▼ ⑥ 业务执行:build_plan(goal) → Plan(typed core)
│
▼ ⑦ response_model 过滤:Plan → PlanResponse 字段(P01 的响应过滤)
│
▼ ⑧ HTTP 响应:201 + {"topic": "Python", "daily_minutes": 102, "steps": [...]}
每一步都有来自不同章节的设计决策支撑。缺少任何一步,客户端收到的结果就可能 与契约不符:
- 没有 ②:请求找不到路由,返回 404
- 没有 ④:无效输入直接进入业务层,可能产生无意义结果或抛出未预期异常
- 没有 ⑤:core 被迫依赖 HTTP 请求模型,传输格式扩散到业务层
- 没有 ⑦:内部字段泄露给客户端
这条数据流就是综合核对的第一个维度:请求数据流的完整性。
2. 核对失败路径的一致性¶
成功路径理顺了,接下来追踪失败。AI 学习助手有两条失败路径,每条对应不同的 处理机制:
路径一:校验失败
客户端发送:POST /plans {"topic": "Python", "weekly_hours": "很多"}
│
▼ Pydantic 校验拒绝:"很多" 无法转为 int
│
▼ FastAPI 内置 handler 捕获 RequestValidationError
│
▼ HTTP 响应:422 + {"detail": [{"type": "int_parsing", "loc": ["body", "weekly_hours"], ...}]}
路径二:业务失败
客户端发送:POST /plans {"topic": "Python", "weekly_hours": 50}
│
▼ Pydantic 校验通过(50 是合法的 int 且 > 0)
│
▼ build_plan 拒绝:weekly_hours 超过 40
│
▼ raise PlanCreationError → exception handler 捕获
│
▼ HTTP 响应:422 + {"error_code": "invalid_plan_params", "message": "...", "field": "weekly_hours"}
现在做一致性核对——把这两条路径与 OpenAPI 声明对比:
| 核对项 | 设计意图(P03) | 实际行为 | OpenAPI 声明(P05) | 一致? |
|---|---|---|---|---|
| 校验失败状态码 | 422 | 422 | 422 (HTTPValidationError) | ✓ |
| 校验失败结构 | detail 数组 | detail 数组 | HTTPValidationError schema | ✓ |
| 业务失败状态码 | 422 | 422 | 422 (ErrorResponse) | ✓ |
| 业务失败结构 | error_code + message + field | error_code + message + field | ErrorResponse schema | ✓ |
如果不一致会怎样?假设 exception handler 实际返回了 409 但 responses 里
只声明了 422——客户端按 OpenAPI 只准备了 422 的处理逻辑,收到 409 时走进了
"未知错误"分支。用户看到"发生了未知错误",但实际原因只是学习时长超过上限。
失败路径的一致性核对:设计意图、实际运行时行为、OpenAPI 文档描述三方必须 对齐。任何一方偏离,客户端就会收到与预期不符的响应。
3. 核对启动链条的完整性¶
前两个维度关注请求级行为——请求来了之后会怎样。但还有一个前提问题:请求能 到达路由吗?
回到本章开头的 404 场景。服务"启动了",但路由没注册。来逐项核对启动链条:
启动命令:uvicorn main:app
│
▼ ① main 是哪个文件?→ main.py
│
▼ ② app 是哪个变量?→ app = FastAPI(lifespan=lifespan)
│
▼ ③ lifespan 注册了吗?→ FastAPI(lifespan=lifespan) ✓
│
▼ ④ lifespan 中初始化完成了吗?→ logger.info("service ready") ✓
│
▼ ⑤ 路由注册了吗?→ app.include_router(router) ✓
│
▼ ⑥ error handlers 注册了吗?→ register_error_handlers(app) ✓
│
▼ 服务进入可用状态,开始接收请求
如果任何一个环节缺失:
| 缺失环节 | 后果 |
|---|---|
main:app 指向错误文件 |
uvicorn 启动失败:ModuleNotFoundError |
app 没有 lifespan 参数 |
服务能跑,但无初始化/清理配对入口 |
| lifespan 初始化失败但被吞掉 | 服务"可用"但核心资源未就绪,请求返回 500 |
app.include_router(router) 缺失 |
服务启动,但所有路由返回 404 |
register_error_handlers(app) 缺失 |
业务异常变成未处理的 500,客户端无法区分错误类型 |
启动链条不是"文件存在"的问题,而是组件之间的注册关系是否完整。每个组件
独立存在没有意义——它们必须通过 FastAPI(lifespan=...) 和
app.include_router(...) 等调用连接在一起。
试一个思想实验:如果 app.include_router(router) 被注释掉,你打开
/openapi.json 会看到什么?——空的 paths: {}。OpenAPI 也看不到路由。
这不只是运行时 404 的问题,连文档都没有这个路径的描述。
4. 用 OpenAPI 作为一致性核对的统一参照物¶
前三个维度分别检查了请求数据流、失败路径和启动链条。但每次核对都要翻代码、 跑请求、比对响应——有没有一个统一的位置可以同时检查多个维度?
答案是 OpenAPI。启动服务后访问 GET /openapi.json,可以一次性核对:
| 核对维度 | 从 OpenAPI 中定位 | 不一致时的信号 |
|---|---|---|
| 路由是否注册 | paths 中是否有 /plans |
缺失 → 路由未注册到 app |
| 请求体 schema | requestBody.schema |
字段与 CreatePlanRequest 不匹配 |
| 成功响应 schema | responses."201".schema |
字段与 PlanResponse 不匹配 |
| 已知错误声明 | responses."422".schema |
缺失 → 业务错误未在 responses 中配置 |
从 OpenAPI 出发做核对,比逐个检查代码文件更高效——因为 OpenAPI 把路由、参数、 请求体、响应和错误的结构化描述集中在一个 JSON 文件中。如果某个维度在 OpenAPI 中缺失或与实际行为不符,就说明存在偏差。
但 OpenAPI 有其局限:它不描述 lifespan 行为,不描述启动命令,不描述适配层的 转换逻辑。所以 OpenAPI 是四维核对中"文档验证"维度的工具,而不是全部维度的 替代。
把四个维度合成一张核对清单:
| 维度 | 核对项 | 来源 | 后续依赖 |
|---|---|---|---|
| 请求数据流 | 参数绑定、Pydantic 校验、适配层转换、业务执行、响应过滤 | P01/P02 | 成功响应与 response_model 一致 |
| 失败路径 | 校验失败状态码和结构、业务失败状态码和映射、错误结构稳定 | P03 | OpenAPI 中 responses 描述完整 |
| 启动链条 | 启动命令→应用入口→lifespan→路由注册→handler 注册 | P04 | 服务可从声明入口启动 |
| 文档验证 | OpenAPI 路径、请求体、成功响应、已知错误与实际行为逐项一致 | P05 | 客户端可信赖文档 |
这四个维度不是独立的检查表——它们互相依赖。路由注册影响 OpenAPI 是否包含 路径;错误结构影响 OpenAPI 的 responses 声明;lifespan 影响服务能否到达 可接收请求的状态。
5. 区分当前必需与后续增量¶
核对完毕,当前 AI 学习助手的最小服务包含:
当前必需:
- 一个路由(POST /plans)
- 一个请求模型(CreatePlanRequest)
- 一个响应模型(PlanResponse)
- 一个错误结构(ErrorResponse)
- 一个 lifespan(最小初始化 + 清理入口)
- OpenAPI 覆盖成功和已知错误
后续增量(本课明确不包含):
- 多路由(GET /plans/{plan_id}、DELETE /plans/{plan_id})
- 数据库依赖注入
- async I/O(W01-L03 的内容)
- pytest 集成测试(W01-L04 的内容)
- 认证中间件
- Docker 部署
关键认知不是"以后要加什么",而是增量入口已经存在:
| 增量需求 | 接入入口 | 说明 |
|---|---|---|
| 新路由 | router.get("/plans/{plan_id}", ...) |
加在同一个 APIRouter 上 |
| 新资源初始化 | lifespan 的 yield 之前 | 配对清理加在 yield 之后 |
| 新错误类型 | 新增 exception handler + responses 声明 |
复用 ErrorResponse 结构 |
| 集成测试 | httpx.AsyncClient(app=app) |
基于公开 HTTP 契约测试 |
HTTP 适配层依赖 typed core 的公开接口(build_plan、LearningGoal)——
这意味着新增功能时,适配层可以调用 core 新增的函数,而不需要穿透 core 内部
实现。"最小化"不是缺少必要组件,而是每个组件只做当前必需的事,同时为增量
预留了明确的接入点。
6. 迁移:为 GET /plans/{plan_id} 做一致性核对¶
理解了核对框架之后,真正的检验是:能否对一个新操作独立应用这套核对?
假设 AI 学习助手需要新增 GET /plans/{plan_id}——获取一个已有的学习计划。
用 P01-P05 的判断框架逐一设计并核对:
路由契约设计(P01 的贡献):
@router.get(
"/plans/{plan_id}",
status_code=200,
response_model=PlanResponse,
responses={
404: {"description": "计划不存在", "model": ErrorResponse},
},
)
async def get_plan(plan_id: str):
goal = find_plan(plan_id) # typed core 的公开接口
if goal is None:
raise PlanNotFoundError(plan_id=plan_id)
return goal
公开契约维度:
- 路径:/plans/{plan_id},方法:GET
- 参数来源:plan_id 来自 path(不是 body)
- 成功状态码:200(资源已存在,返回它)
- 成功响应:PlanResponse
- 已知错误:404 + ErrorResponse
失败路径设计(P03 的贡献):
这个操作只有一种可预期失败——计划不存在。需要设计对应的错误结构:
exception handler 将其映射为:
HTTP/1.1 404 Not Found
{
"error_code": "plan_not_found",
"message": "Plan 'abc123' does not exist",
"field": null
}
注意错误结构复用了 ErrorResponse——与 POST /plans 的业务错误保持同一形状。
客户端只需一套解析逻辑。
OpenAPI 声明(P05 的贡献):
responses 中声明了 404 + ErrorResponse。客户端从文档中可以提前知道:
这个接口可能返回 404,body 结构是 error_code + message + field。
启动链条(P04 的贡献):
新路由加在同一个 router 上,而 router 已经通过 app.include_router(router)
注册到 app。不需要修改 lifespan——获取计划不需要新的应用级资源。
一致性核对清单:
| 维度 | 核对项 | 结论 |
|---|---|---|
| 请求数据流 | path 参数绑定 → core 查询 → response_model 过滤 | 完整 |
| 失败路径 | 404 状态码 + ErrorResponse 结构 + exception handler | 与 OpenAPI 一致 |
| 启动链条 | 路由在已注册的 router 上,不需新增 lifespan 逻辑 | 不受影响 |
| 文档验证 | responses 声明了 404,schema 为 ErrorResponse | 可定位 |
每新增一个操作,都需要按这四个维度核对。这不是机械重复——每个操作的参数来源、 失败类型、是否需要新资源初始化都可能不同。但核对框架是通用的。
边界与常见误区¶
误区一:看到应用入口文件存在就认为服务可启动
文件存在不等于组件已连接。main.py 里定义了 app,但如果
app.include_router(router) 没有执行,路由就是"飘在空中"的——存在于
router.py 文件中,却不在 app 的路由表里。启动后一切"正常",只是所有
请求都返回 404。
最小反例:
uvicorn main:app 正常启动,日志显示 "service ready"。但 curl POST /plans
返回 404。/openapi.json 中 paths 为空对象。
误区二:为后续扩展预建大量空路由和中间件
# 反例:提前创建了 6 个空路由
router.get("/plans/{plan_id}")(lambda: None)
router.delete("/plans/{plan_id}")(lambda: None)
router.post("/goals")(lambda: None)
router.get("/goals/{goal_id}")(lambda: None)
router.post("/sessions")(lambda: None)
router.get("/sessions/{session_id}")(lambda: None)
这些空路由让 OpenAPI 显示了 6 个路径,但每个都返回 null 或 500。客户端
开发者看到文档以为这些接口可用,发请求后得到无意义响应。预建不等于可用——
不如只注册当前能完整工作的路由,让 OpenAPI 准确反映服务的实际能力。
误区三:用 OpenAPI 文档的存在代替运行时行为的核对
"/openapi.json 里有这个路径"不等于"发请求能得到文档描述的响应"。OpenAPI 是
声明,不是保证。如果 exception handler 返回了与 responses 声明不同的结构,
或者 response_model 被 JSONResponse 绕过了,文档和实际行为就产生了偏差。
最小反例:responses 声明 422 返回 ErrorResponse,但 exception handler
实际返回了 {"detail": "error"} 这个不同的结构。文档说一套,服务做另一套。
误区四:把设计判断的一致性核对等同于项目完成
一致性核对证明的是:"当前的设计决策是否互相配合"。它不证明功能已完整交付、 测试已通过、用户可以使用。能解释数据流的每一步设计来源,不等于代码已经写好 并部署上线。核对是设计质量的检查,不是交付状态的证明。
本章小结¶
从追踪一个完整请求的数据流开始,我们把前五章建立的局部设计判断连接成了可核对 的整体。
四维核对提供了综合一致性检查的框架:请求数据流确保每一步转换正确,失败路径 确保错误分类和结构与文档一致,启动链条确保组件之间的注册关系完整,文档验证 确保 OpenAPI 准确反映实际行为。
当前最小服务只需要做好一个操作的完整核对。但增量入口已经存在——新路由加在 router 上,新资源加在 lifespan 中,新错误复用 ErrorResponse 结构。HTTP 适配层依赖 typed core 的公开接口,使后续功能可以增量接入而不需穿透内部实现。
迁移到新操作时,同样的四维核对框架适用:每个新操作都需要回答参数来源、成功 响应、失败类型、是否影响启动链条、OpenAPI 声明是否完整。
练习¶
练习一:追踪数据流并标注来源¶
追踪以下请求经过的完整数据流,对每一步标注它对应哪个 Problem 的判断:
提示:CreatePlanRequest 中 topic 有 Field(min_length=1) 约束。
任务: 1. 这个请求会在哪一步被拒绝? 2. 拒绝后产生的 HTTP 响应是什么状态码和结构? 3. 这个响应在 OpenAPI 中有描述吗?
练习二:核对启动链条¶
以下 main.py 有一个配置错误:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from router import router
import logging
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("service ready")
yield
logger.info("service stopped")
app = FastAPI() # 注意这一行
app.include_router(router)
任务: 1. 指出配置错误在哪里 2. 这个错误会导致什么后果?服务能否启动?路由能否响应? 3. 用四维核对清单中的哪个维度可以发现这个问题?
练习三:诊断一致性偏差¶
团队修改了 PlanCreationError 的 handler,把状态码从 422 改成了 409:
@app.exception_handler(PlanCreationError)
async def handle_plan_creation_error(request, exc):
return JSONResponse(
status_code=409, # 改了
content={"error_code": "invalid_plan_params", "message": exc.reason, "field": exc.field},
)
但 router.py 中的 responses 参数没有更新:
任务:
1. 指出具体的一致性偏差
2. 客户端会遇到什么问题?
3. 应该如何修正(给出修改后的 responses)?
练习四:为 PUT /plans/{plan_id} 设计一致性核对清单¶
假设 AI 学习助手需要新增"更新计划"功能:PUT /plans/{plan_id}。请求体包含
可选的 topic 和 weekly_hours 字段。可能的失败:计划不存在(404)、参数
无效(422)。
任务:用四维核对清单完成设计:
1. 请求数据流:参数来自哪里?需要什么模型?适配层如何转换?
2. 失败路径:有哪些失败类型?各自什么状态码和结构?
3. 启动链条:需要修改 lifespan 吗?路由如何注册?
4. 文档验证:responses 应该声明什么?
练习解析¶
练习一解析¶
数据流追踪:
POST /plans {"topic": "", "weekly_hours": 6}
│
▼ 路由匹配:POST /plans → create_plan(P01)
│
▼ 参数绑定:JSON → CreatePlanRequest(P01)
│
▼ Pydantic 校验:topic="" → min_length=1 约束失败 → ValidationError(P02)
│
▼ FastAPI 内置 handler → HTTP 422 + detail 数组
-
请求在 Pydantic 校验 阶段被拒绝。
topic=""不满足min_length=1约束,属于字段约束校验失败。 -
状态码 422,结构为 FastAPI 默认的校验错误格式:
-
有。FastAPI 自动在 OpenAPI 中添加了 422 +
HTTPValidationError描述。 这是框架默认行为,不需要手动配置responses。
关键理解:因为约束(min_length=1)写在了 Pydantic 模型里,这个失败被归为
校验失败(P02),由框架自动处理和声明。如果约束放在业务层而不是模型里,就变成
业务失败(P03),需要手动配置 exception handler 和 responses。
练习二解析¶
-
错误在
app = FastAPI()——没有传入lifespan=lifespan参数。 lifespan 函数虽然定义了,但没有被应用使用。 -
后果:
- 服务可以启动——
FastAPI()不强制要求 lifespan - 路由可以响应——
app.include_router(router)正常注册了路由 - 但 lifespan 中的初始化和清理代码永远不会执行
-
如果将来 lifespan 中加入了数据库连接初始化,服务会启动但数据库连接未建立
-
用启动链条维度可以发现:核对"应用入口是否注册了 lifespan"这一项时, 会发现
FastAPI(lifespan=lifespan)变成了FastAPI()——链条在这里断了。
练习三解析¶
-
一致性偏差:exception handler 实际返回 409,但
responses只声明了 422。OpenAPI 中没有 409 的描述,实际响应的状态码与文档不一致。 -
客户端问题:
- 客户端按 OpenAPI 只准备了 201(成功)和 422(错误)的处理分支
- 当
weekly_hours > 40时收到 409,但没有对应的处理逻辑 -
409 会落入"未知错误"分支,用户看到通用错误消息而不是"学习时长超过上限"
-
修正后的
responses:
同时需要确认:如果 422 仍然需要(Pydantic 校验错误默认就有),可以保留 FastAPI 的默认 422 描述,或者显式声明两个状态码。
练习四解析¶
1. 请求数据流:
plan_id来自 path 参数(str类型)- 请求体需要新模型
UpdatePlanRequest(topic: str | None = None,weekly_hours: int | None = None,至少一个非 None) - 适配层:先通过
plan_id查找现有计划,再将UpdatePlanRequest的非 None 字段转换为 core 期望的更新参数
2. 失败路径:
| 失败类型 | 状态码 | 结构 | 触发条件 |
|---|---|---|---|
| 校验失败 | 422 | HTTPValidationError(默认) | 字段类型错误 |
| 计划不存在 | 404 | ErrorResponse | plan_id 无对应记录 |
| 参数无效 | 422 | ErrorResponse | 值不满足业务规则 |
3. 启动链条:
- 不需要修改 lifespan——更新计划不需要新的应用级资源
- 路由加在同一个
router上:@router.put("/plans/{plan_id}", ...) - 如果引入了新的异常类型(如
PlanNotFoundError),需要注册对应 handler
4. 文档验证:
responses={
404: {"description": "计划不存在", "model": ErrorResponse},
422: {"description": "更新参数无效", "model": ErrorResponse},
}