跳转至

功能全写完了,但一个请求跑不通?

AI 学习助手的代码已经全部到位:APIRouter 声明了 POST /plansCreatePlanRequest 约束了输入,PlanResponse 过滤了响应,ErrorResponse 定义了错误结构,lifespan 把启动和清理配对好了,OpenAPI 里也补上了已知错误。 每个文件单独看都挺好。

然后你执行 uvicorn main:app,发一个请求——404 Not Found。

路由写了,服务也启动了,为什么 404?你检查了半天,发现 app.include_router(router) 那一行被注释掉了。路由没有注册到应用实例上。

这不是功能缺失——每个"零件"都存在,只是它们没有组装在一起。单独检查每个文件 都没问题,问题出在组件之间的配合关系上。这一章就从一个完整请求出发,沿着数据 流逐步核对:从客户端发出请求到收到响应,每个环节的设计是否彼此一致?

运行环境

Python 3.12、FastAPI 0.115+、Pydantic v2、uvicorn。

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

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_planLearningGoal)—— 这意味着新增功能时,适配层可以调用 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 的贡献)

这个操作只有一种可预期失败——计划不存在。需要设计对应的错误结构:

class PlanNotFoundError(Exception):
    def __init__(self, plan_id: str):
        self.plan_id = plan_id

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。

最小反例:

# main.py
app = FastAPI(lifespan=lifespan)
# 忘了 app.include_router(router)

uvicorn main:app 正常启动,日志显示 "service ready"。但 curl POST /plans 返回 404。/openapi.jsonpaths 为空对象。

误区二:为后续扩展预建大量空路由和中间件

# 反例:提前创建了 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_modelJSONResponse 绕过了,文档和实际行为就产生了偏差。

最小反例:responses 声明 422 返回 ErrorResponse,但 exception handler 实际返回了 {"detail": "error"} 这个不同的结构。文档说一套,服务做另一套。

误区四:把设计判断的一致性核对等同于项目完成

一致性核对证明的是:"当前的设计决策是否互相配合"。它不证明功能已完整交付、 测试已通过、用户可以使用。能解释数据流的每一步设计来源,不等于代码已经写好 并部署上线。核对是设计质量的检查,不是交付状态的证明。

本章小结

从追踪一个完整请求的数据流开始,我们把前五章建立的局部设计判断连接成了可核对 的整体。

四维核对提供了综合一致性检查的框架:请求数据流确保每一步转换正确,失败路径 确保错误分类和结构与文档一致,启动链条确保组件之间的注册关系完整,文档验证 确保 OpenAPI 准确反映实际行为。

当前最小服务只需要做好一个操作的完整核对。但增量入口已经存在——新路由加在 router 上,新资源加在 lifespan 中,新错误复用 ErrorResponse 结构。HTTP 适配层依赖 typed core 的公开接口,使后续功能可以增量接入而不需穿透内部实现。

迁移到新操作时,同样的四维核对框架适用:每个新操作都需要回答参数来源、成功 响应、失败类型、是否影响启动链条、OpenAPI 声明是否完整。

练习

练习一:追踪数据流并标注来源

追踪以下请求经过的完整数据流,对每一步标注它对应哪个 Problem 的判断:

POST /plans {"topic": "", "weekly_hours": 6}

提示:CreatePlanRequesttopicField(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 参数没有更新:

responses={422: {"model": ErrorResponse}}

任务: 1. 指出具体的一致性偏差 2. 客户端会遇到什么问题? 3. 应该如何修正(给出修改后的 responses)?

练习四:为 PUT /plans/{plan_id} 设计一致性核对清单

假设 AI 学习助手需要新增"更新计划"功能:PUT /plans/{plan_id}。请求体包含 可选的 topicweekly_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 数组
  1. 请求在 Pydantic 校验 阶段被拒绝。topic="" 不满足 min_length=1 约束,属于字段约束校验失败。

  2. 状态码 422,结构为 FastAPI 默认的校验错误格式:

    {"detail": [{"type": "string_too_short", "loc": ["body", "topic"], "msg": "String should have at least 1 character", ...}]}
    

  3. 。FastAPI 自动在 OpenAPI 中添加了 422 + HTTPValidationError 描述。 这是框架默认行为,不需要手动配置 responses

关键理解:因为约束(min_length=1)写在了 Pydantic 模型里,这个失败被归为 校验失败(P02),由框架自动处理和声明。如果约束放在业务层而不是模型里,就变成 业务失败(P03),需要手动配置 exception handler 和 responses

练习二解析

  1. 错误在 app = FastAPI()——没有传入 lifespan=lifespan 参数。 lifespan 函数虽然定义了,但没有被应用使用。

  2. 后果:

  3. 服务可以启动——FastAPI() 不强制要求 lifespan
  4. 路由可以响应——app.include_router(router) 正常注册了路由
  5. 但 lifespan 中的初始化和清理代码永远不会执行
  6. 如果将来 lifespan 中加入了数据库连接初始化,服务会启动但数据库连接未建立

  7. 启动链条维度可以发现:核对"应用入口是否注册了 lifespan"这一项时, 会发现 FastAPI(lifespan=lifespan) 变成了 FastAPI()——链条在这里断了。

练习三解析

  1. 一致性偏差:exception handler 实际返回 409,但 responses 只声明了 422。OpenAPI 中没有 409 的描述,实际响应的状态码与文档不一致。

  2. 客户端问题:

  3. 客户端按 OpenAPI 只准备了 201(成功)和 422(错误)的处理分支
  4. weekly_hours > 40 时收到 409,但没有对应的处理逻辑
  5. 409 会落入"未知错误"分支,用户看到通用错误消息而不是"学习时长超过上限"

  6. 修正后的 responses

responses={
    409: {
        "description": "计划参数不满足业务规则",
        "model": ErrorResponse,
    },
}

同时需要确认:如果 422 仍然需要(Pydantic 校验错误默认就有),可以保留 FastAPI 的默认 422 描述,或者显式声明两个状态码。

练习四解析

1. 请求数据流

  • plan_id 来自 path 参数(str 类型)
  • 请求体需要新模型 UpdatePlanRequesttopic: 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},
}

参考资料