一句話先說結論:
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。
References(參考來源)
- Fix: FastAPI 422 Unprocessable Entity — FixDevsblog
- Fix ValidationError: response field required in FastAPI — bugstackbugstack
- Fix FastAPI Model Not Found Error — markaicodeblog
- FastAPI の POST リクエストで発生する 422 エラーの原因と解決法 — Qiitaqiita
- FastAPI Pydantic v2 model validation breaking change — TheNeuralBaseblog
