错误处理与数据校验

写给初学者:每个概念都从”为什么需要它”开始讲,配合通俗比喻和完整可运行代码。


一、Pydantic 数据校验

1.1 为什么需要数据校验?

想象一个注册接口,用户发来这样的 JSON:

 
{"username": "a", "age": -5, "email": "not-an-email"}
 

如果没有校验,这条垃圾数据就直接存进数据库了。Pydantic 的作用就是在数据进入你的函数之前,自动帮你检查数据合不合法。

1.2 BaseModel —— 一切的基础

BaseModel 是 Pydantic 的核心类。你定义一个类继承它,这个类就变成了一个”数据模板”。

 
from pydantic import BaseModel
 
class User(BaseModel):
 
    name: str        # 必须是字符串
 
    age: int         # 必须是整数
 
    email: str       # 必须是字符串
 

发生了什么? 当你用这个类去接收数据时,Pydantic 会自动:

  • 检查 name 是不是字符串 → 不是就报错

  • 检查 age 是不是整数 → 不是就报错

  • 检查 email 是不是字符串 → 不是就报错

  • 如果漏了字段 → 也会报错

 
# ✅ 合法
 
user = User(name="张三", age=25, email="zhangsan@qq.com")
 
# ❌ 报错!age 应该是 int,你给了字符串
 
user = User(name="张三", age="不是数字", email="zhangsan@qq.com")
 
# ❌ 报错!少了 email 字段
 
user = User(name="张三", age=25)
 
# 🎁 额外福利:类型可以自动转换!
 
user = User(name="张三", age="25", email="zhangsan@qq.com")
 
# age 传了字符串 "25",Pydantic 自动转成整数 25,不会报错
 

1.3 Field —— 给字段加更多约束

光有类型检查不够用。比如用户名至少 3 个字符、年龄必须大于 0,这就需要 Field()

 
from pydantic import BaseModel, Field
 
class User(BaseModel):
 
    # ... 表示这个字段是必填的(没有默认值)
 
    # min_length=3 表示最少 3 个字符
 
    name: str = Field(..., min_length=3, max_length=20)
 
    # gt=0 表示 greater than 0(大于 0)
 
    # le=150 表示 less than or equal to 150(小于等于 150)
 
    age: int = Field(..., gt=0, le=150)
 
    # 默认值:如果不传 email,就用 "未填写"
 
    email: str = Field(default="未填写")
 

1.4 field_validator —— 自定义校验规则

有时候内置的约束不够用,比如”邮箱必须包含 @”、“密码必须包含数字”,这就需要自定义校验器。

 
from pydantic import BaseModel, Field, field_validator
 
class User(BaseModel):
 
    name: str = Field(..., min_length=3)
 
    email: str
 
    password: str = Field(..., min_length=8)
 
    # @field_validator("email") 表示:这个函数用来校验 email 字段
 
    # 参数 v 就是用户传进来的 email 的值
 
    # 如果校验不通过,raise ValueError("错误信息")
 
    # 如果校验通过,return v(可以顺便做转换,比如转小写)
 
    @field_validator("email")
 
    @classmethod
 
    def check_email(cls, v: str) -> str:
 
        # cls 指的是这个类本身(User),不用管它,固定写法
 
        # v 是用户传入的 email 值
 
        if "@" not in v:
 
            raise ValueError("邮箱必须包含 @ 符号")
 
        return v.lower()  # 校验通过,顺便把邮箱转成小写
 
    @field_validator("password")
 
    @classmethod
 
    def check_password(cls, v: str) -> str:
 
        if not any(char.isdigit() for char in v):
 
            raise ValueError("密码必须包含至少一个数字")
 
        return v
 

运行效果:

 
# ✅ 合法,email 自动转小写
 
user = User(name="张三", email="ZhangSan@QQ.COM", password="abc12345")
 
print(user.email)  # "zhangsan@qq.com"
 
# ❌ 报错:邮箱没有 @
 
user = User(name="张三", email="zhangsan.com", password="abc12345")
 
# ❌ 报错:密码没有数字
 
user = User(name="张三", email="a@b.com", password="abcdefgh")
 
# → ValidationError: 密码必须包含至少一个数字
 

1.5 常见错误(踩坑记录)

学 Pydantic 最容易踩的几个坑,用一个”错误代码 → 修正”的方式整理:

坑 1:Field 约束类型用错

 
# ❌ 错误:给字符串字段加了数值约束
 
email: str = Field(..., gt=0, le=150)
 
# ✅ 正确:字符串用 min_length / max_length
 
email: str = Field(..., min_length=5, max_length=150)
 

为什么错? gtgeltle 是给数字用的(大于、大于等于、小于、小于等于)。字符串长度要用 min_lengthmax_length

坑 2:忘了给必填字段加约束

 
# ❌ 错误:age 没有任何约束,负数也能通过
 
age: int
 
# ✅ 正确:加个合理范围
 
age: int = Field(..., gt=0, le=150)
 

为什么错? 没有约束 = 只检查类型。age = -999 也会通过校验,因为它确实是 int

速查:Field 约束该用哪个?

数据类型约束类型例子
字符串长度min_length / max_lengthField(min_length=3, max_length=20)
数字大小gt / ge / lt / leField(gt=0, le=150)
必填... 或不给 defaultField(...)

二、错误处理(Exception Handling)

2.1 为什么需要错误处理?

假设用户访问 /users/999,但用户 999 不存在。你不能什么都不说,也不能返回一个丑陋的 500 错误。你需要返回一个友好的、结构化的错误信息

2.2 HTTPException —— 最常用的错误处理

 
from fastapi import FastAPI, HTTPException
 
app = FastAPI()
 
fake_users = {
 
    1: {"id": 1, "name": "张三"},
 
    2: {"id": 2, "name": "李四"},
 
}
 
@app.get("/users/{user_id}")
 
async def get_user(user_id: int):
 
    # 如果用户不存在,抛出 HTTPException
 
    if user_id not in fake_users:
 
        raise HTTPException(
 
            status_code=404,           # 状态码
 
            detail=f"用户 {user_id} 不存在"  # 错误详情
 
        )
 
    # 如果用户存在,正常返回
 
    return fake_users[user_id]
 

客户端收到的错误响应:

 
{
 
  "detail": "用户 999 不存在"
 
}
 

HTTPException 的参数:

 
raise HTTPException(
 
    status_code=404,                    # 必填:状态码
 
    detail="用户不存在",                  # 必填:错误描述
 
    headers={"X-Error-Code": "U001"}   # 可选:自定义响应头
 
)
 

2.3 不同场景用不同的状态码

 
@app.post("/login")
 
async def login(username: str, password: str):
 
    # 1. 用户不存在 → 404
 
    if username not in db:
 
        raise HTTPException(status_code=404, detail="用户不存在")
 
    # 2. 密码错误 → 401(Unauthorized,未授权)
 
    if db[username]["password"] != password:
 
        raise HTTPException(status_code=401, detail="密码错误")
 
    # 3. 账号被封 → 403(Forbidden,禁止访问)
 
    if db[username]["banned"]:
 
        raise HTTPException(status_code=403, detail="账号已被封禁")
 
    # 4. 全部通过 → 200
 
    return {"msg": "登录成功", "token": "xxx"}
 

2.4 自定义异常类 —— 处理业务逻辑错误

当你的业务逻辑比较复杂(比如支付、库存扣减),可以定义自己的异常类,让代码更清晰。

步骤:① 定义异常类 → ② 注册处理器 → ③ 在业务代码里抛出

 
from fastapi import FastAPI, Request
 
from fastapi.responses import JSONResponse
 
app = FastAPI()
 
# 继承 Exception,就像定义一个普通的 Python 类
 
class InsufficientBalance(Exception):
 
    """余额不足异常"""
 
    def __init__(self, balance: float, required: float):
 
        self.balance = balance    # 当前余额
 
        self.required = required  # 需要的金额
 
class OutOfStock(Exception):
 
    """库存不足异常"""
 
    def __init__(self, product: str, available: int, requested: int):
 
        self.product = product
 
        self.available = available
 
        self.requested = requested
 
# 告诉 FastAPI:"当 InsufficientBalance 异常发生时,用这个函数处理"
 
@app.exception_handler(InsufficientBalance)
 
async def handle_insufficient_balance(request: Request, exc: InsufficientBalance):
 
    return JSONResponse(
 
        status_code=400,  # 返回 400 错误
 
        content={
 
            "error": "余额不足",
 
            "your_balance": exc.balance,
 
            "required": exc.required,
 
            "shortage": round(exc.required - exc.balance, 2)
 
        }
 
    )
 
@app.exception_handler(OutOfStock)
 
async def handle_out_of_stock(request: Request, exc: OutOfStock):
 
    return JSONResponse(
 
        status_code=400,
 
        content={
 
            "error": "库存不足",
 
            "product": exc.product,
 
            "available": exc.available,
 
            "requested": exc.requested
 
        }
 
    )
 
# ========== 第三步:在业务代码里抛出异常 ==========
 
my_balance = 50.0
 
@app.post("/buy/{product}")
 
async def buy_product(product: str, quantity: int = 1):
 
    prices = {"apple": 30.0, "banana": 20.0}
 
    stock = {"apple": 10, "banana": 0}
 
    if product not in prices:
 
        raise HTTPException(status_code=404, detail="商品不存在")
 
    total_price = prices[product] * quantity
 
    # 检查库存(抛出自定义异常)
 
    if stock[product] < quantity:
 
        raise OutOfStock(product=product, available=stock[product], requested=quantity)
 
    # 检查余额(抛出自定义异常)
 
    if my_balance < total_price:
 
        raise InsufficientBalance(balance=my_balance, required=total_price)
 
    return {"msg": f"购买 {quantity}{product} 成功"}
 

为什么要用自定义异常而不是直接用 HTTPException?

 
# ❌ 用 HTTPException:错误信息散落在业务代码里,不好维护
 
if my_balance < total_price:
 
    raise HTTPException(status_code=400, detail="余额不足")
 
# 错误格式、状态码等由异常处理器统一管理
 
if my_balance < total_price:
 
    raise InsufficientBalance(balance=my_balance, required=total_price)
 

2.5 全局异常处理器 —— 兜底方案

 
@app.exception_handler(Exception)
 
async def global_exception_handler(request: Request, exc: Exception):
 
    """
 
    捕获所有未被其他处理器捕获的异常
 
    相当于 try-except 的"最后防线"
 
    防止服务器返回难看的默认 500 错误
 
    """
 
    print(f"未预期的错误: {type(exc).__name__}: {exc}")  # 打印日志方便排查
 
    return JSONResponse(
 
        status_code=500,
 
        content={
 
            "error": "服务器内部错误",
 
            "detail": "请联系管理员"
 
        }
 
    )
 

2.6 自定义 Pydantic 校验错误格式(进阶)

FastAPI 默认的 422 错误格式比较长,你可以自定义成更简洁的格式:

 
from fastapi.exceptions import RequestValidationError
 
@app.exception_handler(RequestValidationError)
 
async def validation_exception_handler(request: Request, exc: RequestValidationError):
 
    # 把 FastAPI 默认的长格式简化
 
    errors = []
 
    for error in exc.errors():
 
        field = " -> ".join(str(loc) for loc in error["loc"])
 
        errors.append(f"{field}: {error['msg']}")
 
    return JSONResponse(
 
        status_code=422,
 
        content={"error": "数据校验失败", "details": errors}
 
    )
 

速查表

概念一句话解释关键代码
Pydantic BaseModel定义数据结构,自动校验类型class User(BaseModel): ...
Field给字段加约束(长度、范围)name: str = Field(..., min_length=3)
field_validator自定义校验规则@field_validator("email")
HTTPException抛出 HTTP 错误(最常用)raise HTTPException(status_code=404, detail="...")
自定义异常业务逻辑异常,统一格式处理class MyError(Exception): ...
exception_handler注册异常处理器@app.exception_handler(MyError)
全局异常处理兜底,防止裸 500@app.exception_handler(Exception)

▶ 对应原理:09-全局异常处理

速记卡(面试闪卡)

Q1:一句话讲清「错误处理与数据校验」到底是什么?

A:FastAPI 用 Pydantic 在进函数前校验数据,用 HTTPException/异常处理器返回友好错误。

Q2:一、Pydantic 数据校验 —— 怎么理解?

A:BaseModel 是数据模板,收到数据自动查类型、查必填;Field 加约束(min_length、gt/le);field_validator 写自定义规则(邮箱要带@)。就像进门安检——垃圾数据(age=-5、邮箱乱写)根本进不了你的函数。

Q3:二、HTTPException 错误处理 —— 怎么理解?

A:用户不存在就 raise HTTPException(status_code=404, detail=…),客户端拿到结构化 {“detail”:…} 而非丑陋的 500。不同场景用不同码:404 不存在、401 密码错、403 被封、200 成功,语义清晰。

Q4:三、自定义异常与处理器 —— 怎么理解?

A:支付/库存这类复杂逻辑,定义自己的异常类(如 InsufficientBalance),用 @app.exception_handler 注册统一格式的处理器。业务代码只扔”余额不足”这个事实,错误长什么样、什么状态码由处理器统一管。

Q5:四、全局兜底与校验美化 —— 怎么理解?

A:@app.exception_handler(Exception) 是最后防线,兜住所有漏网的异常,防止裸 500 吓到用户;还可重写 RequestValidationError,把默认 422 的长格式压成简洁的 {“error”,“details”},前端好解析。

Q6:核心速记主线有哪些?

  • Pydantic BaseModel 自动校验类型与必填

  • Field 加长度/范围约束,field_validator 写自定义规则

  • HTTPException 抛友好错误(404/401/403)

  • 自定义异常 + 处理器统一错误格式

  • 全局处理器兜底防裸 500

口诀

A:Pydantic 先把数据验,

Field 约束规则定底线;

异常抛出处理器管,

HTTPException 礼貌相还。

相关链接


技术学习路线图 > 进阶篇

相关链接