一句话先说结论:
422 Unprocessable Entity不是鉴权、不是接口没写,而是 FastAPI 用 Pydantic 校验你的请求,发现数据跟模型定义对不上。最快的排查就是读响应体detail数组里的loc(错在哪)、msg(为什么错)、type(错误类型)、input(实际收到的值)。修复大多落在三件事上:别用 ORM 模型直接收请求、字段名/类型对齐、Optional 字段给默认值。
背景
FastAPI 的一个核心卖点就是“用类型注解自动做校验”:路由函数参数标注成 Pydantic 模型后,框架在处理请求前先跑一遍校验,能强转的强转(字符串 "5" 转 int、ISO 日期转 datetime),不能过的直接甩 422,根本轮不到你的业务代码执行。
这个 422 区别于 400(JSON 根本不是合法 JSON)、415(Content-Type 不对)、500(代码崩了)——它含义明确:请求到达了、格式也懂,但“内容”不符合契约。
现象
请求失败,响应体长这样:
{
"detail": [
{
"type": "missing",
"loc": ["body", "character_id"],
"msg": "Field required",
"input": { "user_message": "hello" }
},
{
"type": "int_parsing",
"loc": ["body", "age"],
"msg": "Input should be a valid integer",
"input": "abc"
}
]
}
四个字段就是排查的全部线索:
| 字段 | 含义 |
|---|---|
loc |
出错位置,["body","字段名"] 是请求体、["query","x"] 是查询参数、["path","x"] 是路径参数 |
msg |
人类可读的描述,如 Field required、Input should be a valid integer |
type |
机器可读的错误类型,如 missing、int_parsing、string_type |
input |
实际收到的值 |
根因分析
422 的高频来源,基本能归成这几类:
- 必填字段缺失:模型字段没默认值,客户端没传。
- 类型不匹配:该是
int传了字符串,该是对象传了列表。 - 字段名对不上:最常见是前后端大小写规范不一致——前端
userId,后端user_id。 - Content-Type 不对:该发 JSON 却发了表单,或反之;FastAPI 按 header 决定怎么解析 body。
- 嵌套层级错位:模型要
{"profile": {"age": 25}},客户端却平铺成了{"age": 25}。 - 拿 ORM 模型直接收请求:把
Conversation(带id、timestamp这类不该由客户端填的字段)当请求体模型,天然容易触发**「没传 id 就缺字段」**,或者反过来放行本不该暴露的字段。
解决方案
方案 A:用专用输入模型(DTO),别用 ORM 模型收请求
这是最该改的结构性问题。定义只含客户端该传字段的输入模型:
from pydantic import BaseModel
class CreateConversationRequest(BaseModel):
character_id: int
user_message: str
路由用它接收,再手动构造 ORM 对象:
@router.post("/chat")
def chat(data: CreateConversationRequest, session: Session = Depends(get_session)):
conversation = Conversation(
user_id=current_user_id,
character_id=data.character_id,
user_message=data.user_message,
)
session.add(conversation)
session.commit()
return {"success": True}
这样 id、user_id、timestamp 这些数据库侧字段不会被客户端塞进来,契约清晰。
方案 B:Optional 字段给默认值
不确定一定有的字段,标 Optional 并给默认:
from typing import Optional
class UserCreate(BaseModel):
name: str # 必填
email: str # 必填
age: Optional[int] = None # 可空
bio: str = "" # 可选,默认空串
方案 C:字段名用别名对齐前端
前端 camelCase、后端 snake_case,用 Field(alias=...) 在“对外 JSON key”和“内部变量名”之间搭桥:
from pydantic import BaseModel, Field
class Item(BaseModel):
character_id: int = Field(alias="characterId")
model_config = {"populate_by_name": True} # 允许两种名字都收
方案 D:控制未知字段策略
extra 决定遇到模型里没定义的字段怎么办:
from pydantic import ConfigDict
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid") # 多余字段直接报错(推荐生产)
class LooseModel(BaseModel):
model_config = ConfigDict(extra="ignore") # 忽略多余字段
排查阶段临时用 extra="allow",能帮你一眼看出客户端到底多发/漏发了哪些字段。
一个升级坑:Pydantic v1 → v2
FastAPI 0.100 起切到了 Pydantic v2,错误格式和写法都变了,升级时容易踩:
- 错误
type码变了(v1 的value_error.missing/type_error.integer变成 v2 的missing/int_parsing)。 class Config改为model_config = ConfigDict(...)。@validator/@root_validator改为@field_validator/@model_validator。parse_obj()改为model_validate(),parse_raw()改为model_validate_json()。
如果自定义了 422 错误处理器,注意 exc.errors() 在 v2 里返回的是 dict 列表,不是 v1 的 tuple 列表。
小结
- 422 的第一动作永远是读
detail的loc+msg+type,别瞎猜。 - 结构性建议:request 用独立输入模型、response 用输出模型,把 ORM 模型和 API 契约隔开。
- 字段名对齐(
alias)、可选字段给默认、extra策略,这三招覆盖大部分场景。 - 升级 Pydantic v2 时,把校验器和 config 写法一起迁过去,顺手检查自定义 422 handler。