3.3 让校验错误与业务错误各归其位
让校验错误与业务错误各归其位
接口失败时,调用方需要知道是请求格式不对、资源不存在,还是请求与当前业务状态冲突。把这些情况都写成 ValueError,客户端只能看到一个模糊的 500;把所有异常都直接返回,又可能泄露内部数据。清晰的做法是:模型校验负责输入形状,服务层负责业务状态,路由边界把公开错误映射成稳定的 HTTP 响应。
field_validator 负责字段内规则
Pydantic v2 使用 field_validator 声明字段校验器。校验器应该接收已经解析的字段值,发现不符合输入规则时抛出 ValueError;当模型用于 FastAPI 请求体时,这个错误会被包装成请求校验错误并返回 422。下面的保留标题检查只依赖输入值,没有查询数据库,也没有依赖当前登录用户。
<!-- file: ch03_contracts/error_boundaries.py -->
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
class PostCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=3, max_length=120)
content: str = Field(min_length=1, max_length=10_000)
@field_validator("title")
@classmethod
def reject_reserved_title(cls, value: str) -> str:
normalized = value.strip()
if len(normalized) < 3:
raise ValueError("标题去除首尾空白后至少需要 3 个字符")
if normalized.lower() == "internal-only":
raise ValueError("标题包含保留词")
return normalized
class PostResponse(BaseModel):
id: int
title: str
content: str
author_id: int
created_at: datetime
app = FastAPI(title="错误边界示例")
POSTS: dict[int, dict[str, object]] = {}
@app.exception_handler(RequestValidationError)
async def public_request_error(
_request: object,
_exc: RequestValidationError,
) -> JSONResponse:
# 对外只给稳定的公共消息,避免把输入值或内部字段名带回客户端。
return JSONResponse(status_code=422, content={"detail": "请求数据不符合格式"})
@app.post("/posts", response_model=PostResponse, status_code=201)
def create_post(payload: PostCreate) -> dict[str, object]:
if any(post["title"] == payload.title for post in POSTS.values()):
raise HTTPException(status_code=409, detail="文章标题已存在")
post_id = max(POSTS, default=0) + 1
record: dict[str, object] = {
"id": post_id,
**payload.model_dump(),
"author_id": 7,
"created_at": datetime.now(timezone.utc),
}
POSTS[post_id] = record
return record
@app.get("/posts/{post_id}", response_model=PostResponse)
def get_post(post_id: int) -> dict[str, object]:
post = POSTS.get(post_id)
if post is None:
raise HTTPException(status_code=404, detail="文章不存在")
return post
@app.post("/manual-check")
def manual_check(payload: dict[str, object]) -> dict[str, str]:
# 这里是代码主动构造模型的边界;ValidationError 不会自动成为 422。
try:
post = PostCreate.model_validate(payload)
except ValidationError as exc:
raise HTTPException(status_code=422, detail="请求数据不符合格式") from exc
return {"title": post.title}
@app.get("/broken", response_model=PostResponse)
def broken_response() -> dict[str, object]:
# 故意缺少 content,演示响应契约被服务端破坏时不应伪装成客户端错误。
return {
"id": 99,
"title": "不完整响应",
"author_id": 7,
"created_at": datetime.now(timezone.utc),
}
def self_check() -> None:
POSTS.clear()
with TestClient(app, raise_server_exceptions=False) as client:
malformed = client.post("/posts", json={"title": "x", "content": "正文"})
assert malformed.status_code == 422
assert malformed.json() == {"detail": "请求数据不符合格式"}
reserved = client.post(
"/posts", json={"title": "internal-only", "content": "正文"}
)
assert reserved.status_code == 422
whitespace = client.post(
"/posts", json={"title": " ", "content": "正文"}
)
assert whitespace.status_code == 422
manual = client.post("/manual-check", json={"title": "x"})
assert manual.status_code == 422
assert manual.json() == {"detail": "请求数据不符合格式"}
created = client.post(
"/posts", json={"title": "唯一文章", "content": "正文"}
)
duplicate = client.post(
"/posts", json={"title": "唯一文章", "content": "另一份正文"}
)
assert created.status_code == 201
assert duplicate.status_code == 409
assert duplicate.json() == {"detail": "文章标题已存在"}
missing = client.get("/posts/404")
assert missing.status_code == 404
assert missing.json() == {"detail": "文章不存在"}
response_failure = client.get("/broken")
assert response_failure.status_code == 500
assert "content" not in response_failure.text
# 直接构造模型时,ValueError 会被 Pydantic 聚合成 ValidationError。
try:
PostCreate.model_validate({"title": "x", "content": "正文"})
except ValidationError as exc:
assert any(error["loc"] == ("title",) for error in exc.errors())
else:
raise AssertionError("手工构造应产生 ValidationError")
print("error_boundaries self-check passed")
if __name__ == "__main__":
self_check()
在保存文件的目录运行:
python ch03_contracts/error_boundaries.py
预期输出为 error_boundaries self-check passed。其中 500 是故意破坏响应模型得到的服务端错误;TestClient(raise_server_exceptions=False) 让自检可以观察到 HTTP 响应,而不是把异常直接抛回测试进程。
四类失败的边界
| 情况 | 示例 | 对外结果 | 责任位置 |
|---|---|---|---|
| 请求校验失败 | 标题太短、缺少正文、保留词 | 422 | Pydantic 请求模型和请求异常处理器 |
| 资源不存在 | 查询 ID 没有对应文章 | 404 | 服务层查询结果和路由映射 |
| 状态冲突 | 标题已被使用、重复创建 | 409 | 业务服务或数据库约束映射 |
| 响应校验失败 | 服务端漏返回 content |
500 | 服务端实现和响应模型 |
422 表示请求已经到达接口,但没有满足输入契约;404 表示目标资源不存在;409 表示请求格式可以理解,却与当前资源状态冲突。状态码是面向调用方的协议,不是把 Python 异常名称直接翻译过去:服务函数中的普通 ValueError 如果没有被捕获,不能保证自动变成 422。
手工构造模型时要显式处理异常
请求参数声明为 payload: PostCreate 时,FastAPI 会负责请求校验。代码从字典、数据库记录或消息队列中主动调用 PostCreate.model_validate(...) 时,产生的是 Pydantic ValidationError;如果它发生在请求处理过程中,应根据来源决定是返回公开的 422,还是记录内部错误并返回 500。示例的 /manual-check 捕获了异常,只返回固定消息,没有把字段输入值和详细解析内容回传。
field_validator 抛出的 ValueError 只适合表达字段或模型输入规则。如果规则需要查询“标题是否已存在”、判断当前用户是否有权修改,应该移到服务层;服务层发现资源不存在时抛出明确的领域结果,由路由映射为 404 或 409。这样模型可以在导入和单元测试时独立使用,不会隐藏数据库访问。
不把敏感值放进公开错误
默认 Pydantic 错误详情可能包含字段位置和输入摘要。对密码、令牌、密钥等字段,不要把原始值放进自定义异常消息,也不要把完整 ValidationError 原样返回给外部客户端。可以像示例一样在请求异常处理器中返回稳定的公共消息,同时把详细错误仅写入经过脱敏的内部日志;生产日志还需要避免打印完整请求体。
响应校验失败也不应把 Pydantic 内部错误直接展示给调用方。它通常表示服务端代码、序列化或数据库映射有缺陷,应在监控和测试中发现并修复,客户端只需要得到统一的服务端错误响应。
资料来源
- 主要参考:fastapi-best-practices 中文 README 的“ValueErrors 可能会变成 Pydantic ValidationError”主题。本节补全了字段定义和导入,并加入请求、业务冲突、资源缺失和响应校验的可运行边界。
- 官方文档:Pydantic 验证器、FastAPI 处理错误、FastAPI 请求验证错误。