FastAPI响应处理:返回值、状态码、响应头与异常标准化与案例解析

案例背景:我们要做什么?

假设你接到一个需求:实现一组用户CRUD接口,并满足以下要求:

  1. 所有成功/失败响应格式统一,前端只需写一套解析逻辑
  2. 绝不能把数据库里的hashed_password返回给前端
  3. 创建用户返回201,删除成功返回204
  4. 用户列表接口要在响应头里带上总条数X-Total-Count
  5. 支持文件下载、实时日志推送、页面重定向
  6. 404、参数校验失败、业务规则冲突等错误,格式也要和成功体一致

如果你只会返回字典,代码大概长这样:

python 复制代码
@app.get("/users/1")
def get_user():
    return {"id": 1, "name": "Alice", "hashed_password": "secret"}

@app.post("/users")
def create_user():
    return {"ok": True}  # 状态码还是 200,格式也和查询接口不一样

本文将进行从零搭建app/目录下的完整案例,一步步替换掉这些能跑但不规范的写法。

项目结构

推荐创建一个和我的demo一样的目录

bash 复制代码
app/
├── main.py                 # 入口,注册路由、异常处理器
├── core/
│   ├── response.py         # 统一返回体 ApiResponse
│   ├── exceptions.py       # 业务异常 BusinessException
│   └── handlers.py         # 全局异常处理
├── schemas/
│   └── user.py             # 用户数据模型
├── routers/
│   ├── users.py            # 用户CRUD(本文重点)
│   ├── files.py            # 文件下载
│   └── logs.py             # 流式日志
└── static/files/sample.txt # 下载用的示例文件

一.定义数据模型,解决密码泄露问题

案例场景:数据库存的是完整的用户记录包含密码哈希,但API只能返回公开字段。 案例开始,先定义三套模型:

python 复制代码
class UserDB(BaseModel):
    """模拟数据库记录,只在服务端内部使用"""
    id: int
    name: str
    email: EmailStr
    hashed_password: str

class UserOut(BaseModel):
    """对外 API 出参,不含密码"""
    id: int
    name: str
    email: EmailStr

class UserCreate(BaseModel):
    """创建用户的入参"""
    name: str = Field(..., min_length=1, max_length=50)
    email: EmailStr

这是自定义数据模型DB模型与API模型分离,从设计上防止敏感字段流出。 值得注意的是使用Pydanticv2进行模型互转的时候从UserDB转成UserOut不能写成:

python 复制代码
UserOut.model_validate(user_db)

必须先把模型转换成字典

python 复制代码
def to_user_out(userL UserDB) -> UserOut:
      return UserOut.model_validate(user.model_dump())

二.返回统一的返回体

前端通常需要返回接口格式统一,例如:

json 复制代码
{
      "code": 0,
      "msg": "查询成功",
      "data": {...}
}

可以实现返回格式的统一,定义如下:

python 复制代码
class ApiResponse(BaseModel, Generic[T]):
      code: int = 0
      msg: str = "success"
      data: T | None = None
      
def success(*, data=None, msg="success", code=0)
      return ApiResponse(code=code, msg=msg, data=data)

为什么要使用泛型ApiResponse[T]?是因为在ApiResponse[UserOut]时,IDE和Swagger都知道data里是用户对象,写ApiResponse[list[UserOut]]时,data是用户列表。 后面所有用户接口的成功返回都通过return success(data=..., msg="...")完成。

三.查询单个用户response_model与自动序列化

举例:

python 复制代码
@router.get("/{user_id}", response_model=ApiResponse[UserOut])
def get_user(user_id: int) -> ApiResponse[UserOut]:
    if user_id <= 0:
        raise HTTPException(status_code=404, detail="用户不存在")
    user = _FAKE_USERS.get(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="用户不存在")
    return success(data=to_user_out(user), msg="查询成功")

这里体现了自动类型序列化的核心机制

写的 FastAPI 做的
response_model=ApiResponse[UserOut] 按模型过滤、校验、序列化 JSON
return success(data=to_user_out(user)) 把Python对象转成符合契约的响应
to_user_out(user) 确保data里只有 id/name/email
运行结果如图所示:

在上面的返回中hashed_password从未出现在响应中,这就是response_model+独立出参模型的价值。

四.查询用户列表-响应头与列表序列化

在下面的案例中除了返回用户列表,还需要在响应头告诉前端一共有多少条记录。

python 复制代码
@router.get("", response_model=ApiResponse[list[UserOut]])
def list_users(response: Response) -> ApiResponse[list[UserOut]]:
    users = [to_user_out(u) for u in _FAKE_USERS.values()]
    response.headers["X-Total-Count"] = str(len(users))
    return success(data=users, msg="查询成功")

这里用到了自定义响应头的第二种写法:注入Response对象,动态设置 header,下面是接口的返回结果,如图所示: 除了JSON响应体,响应头会有X-Total-Count:2

五.两种精简字段的写法

有些页面只需要idname,不想暴露email,可以参考下面的两种写法:

python 复制代码
@router.get("/{user_id}/brief", response_model=ApiResponse[UserBrief])
def get_user_brief(user_id: int):
  ...
  return success(data=UserBrief(id=user.id, name=user.name), msg="查询成功")

上面的第一种写法UserBrief只定义了id与name,Swagger文档一目了然。 第二种写法是采用response_model_exclude排除字段:

python 复制代码
@router.get(
    "/{user_id}/exclude-demo",
    response_model=UserOut,
    response_model_exclude={"email"},
)
def get_user_exclude_email(user_id: int) -> UserOut:
    ...
    return UserOut.model_validate(user.model_dump())

这个接口没有在ApiResponse里,直接返回UserOut,但排除了email字段。返回案例如图所示: 其中exclude参数还可以按照下表进行设置:

参数 作用
response_model_include 白名单,只保留指定字段
response_model_exclude 黑名单,排除指定字段
response_model_exclude_unset 排除未赋值的字段
response_model_exclude_none 排除值为None的字段

如果是需要长期维护接口,有限使用写法1。

六.创建用户与删除用户代码

创建资源成功,HTTP状态码应该是201,而不是默认的200,例如:

python 复制代码
@router.post("", response_model=ApiResponse[UserOut], status_code=status.HTTP_201_CREATED)
def create_user(payload: UserCreate) -> ApiResponse[UserOut]:
    ...
    return success(data=to_user_out(user), msg="创建成功")

推荐使用状态码枚举,例如:

python 复制代码
from starlette import status

status.HTTP_200_OK        # 查询成功
status.HTTP_201_CREATED   # 创建成功
status.HTTP_204_NO_CONTENT  # 删除成功,无响应体
status.HTTP_404_NOT_FOUND   # 资源不存在

用枚举而不是裸写201,IDE有自动补全,也不容易写错,上面代码响应案例如图所示: 在删除成功时,通常不需要返回JSON body,例如:

python 复制代码
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int) -> None:
    if user_id not in _FAKE_USERS:
        raise HTTPException(status_code=404, detail="用户不存在")
    del _FAKE_USERS[user_id]
    # 不需要 return 任何内容

七. 抛出异常

查询不存在的用户,不应该return {"code": 404, ...},而是直接抛异常,让全局处理器统一格式化。

python 复制代码
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")

八.业务异常

禁止注册特定邮箱,例如@blocked.com 域名的邮箱不允许注册。这是业务规则错误,不是HTTP层面的404。

python 复制代码
class BusinessException(Exception):
    def __init__(self, code: int, msg: str, http_status: int = 400):
        self.code = code
        self.msg = msg
        self.http_status = http_status

在创建用户路由中使用:

python 复制代码
if payload.email.endswith("@blocked.com"):
    raise BusinessException(code=40001, msg="该邮箱域名已被禁止注册", http_status=403)

其返回示例为:

九.全局异常处理

不管哪里出错,前端收到的格式都一样。 示例代码为:

python 复制代码
def register_exception_handlers(app: FastAPI) -> None:

    @app.exception_handler(BusinessException)
    async def business_exception_handler(_, exc):
        return JSONResponse(
            status_code=exc.http_status,
            content={"code": exc.code, "msg": exc.msg, "data": None},
        )

    @app.exception_handler(HTTPException)
    async def http_exception_handler(_, exc):
        return JSONResponse(
            status_code=exc.status_code,
            content={"code": exc.status_code, "msg": str(exc.detail), "data": None},
        )

    @app.exception_handler(RequestValidationError)
    async def validation_exception_handler(_, exc):
        return JSONResponse(
            status_code=422,
            content={"code": 422, "msg": "参数校验失败", "data": exc.errors()},
        )

    @app.exception_handler(Exception)
    async def unhandled_exception_handler(_, exc):
        return JSONResponse(
            status_code=500,
            content={"code": 500, "msg": "服务器内部错误", "data": str(exc)},
        )

需要在main.py中进行注册

python 复制代码
register_exception_handlers(app)

实现的效果如下表所示:

触发方式 HTTP 状态码 响应体
/api/users/-1获取不存在的用户 404 {"code":404,"msg":"用户不存在","data":null}
/api/users 传空name 422 {"code":422,"msg":"参数校验失败","data":[...]}
注册@blocked.com邮箱 403 {"code":40001,"msg":"该邮箱域名已被禁止注册","data":null}
代码未捕获的异常 500 {"code":500,"msg":"服务器内部错误","data":"..."}

现在回头看第七节的404错误,已经被自动包装成统一格式,前端无需为每种错误写不同解析逻辑。

十.文件下载

提供一个示例文件供用户下载。这类响应不是JSON,不能用ApiResponse包装,提供下面的示例代码:

python 复制代码
@router.get("/download")
def download_sample() -> FileResponse:
    file_path = _FILES_DIR / "sample.txt"
    return FileResponse(
        path=file_path,
        filename="示例文档.txt",
        media_type="text/plain; charset=utf-8",
    )

浏览器访问时,会弹出另存为对话框。filename参数决定了下载时显示的文件名。

十一.实时日志推送

运维页面需要实时看到服务日志,类似SSE(Server-Sent Events),代码示例为:

python 复制代码
async def _log_event_generator():
    lines = ["[INFO] 服务启动中...", "[INFO] 加载配置完成", ...]
    for line in lines:
        await asyncio.sleep(0.8)
        yield f"data: {line}\n\n"   # SSE 格式
    yield "data: [DONE]\n\n"

@router.get("/stream")
async def stream_logs() -> StreamingResponse:
    return StreamingResponse(
        _log_event_generator(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
    )

你会看到日志每隔0.8秒逐行输出,而不是等全部生成完才返回。这就是流式响应,适合大文件分块传输、实时推送、AI逐字输出等场景,响应效果如下:

十二.页面重定向

例旧地址/api/redirect/demo需要跳转到Swagger文档页,示例代码:

python 复制代码
@app.get("/api/redirect/demo", status_code=status.HTTP_302_FOUND)
def redirect_demo() -> RedirectResponse:
    return RedirectResponse(url="/docs", status_code=status.HTTP_302_FOUND)

进行重定向需要根据情况,如下表所示:

状态码 语义
301 永久重定向
302 临时重定向
307 临时重定向,保持原始请求方法

响应头中会出现 Location: /docs,浏览器会自动跳转,如图所示:

总结

回顾文章,从最简单的return {"key": "value"}出发,逐步构建了:

  1. 数据模型分层 (UserDB/UserOut/ UserBrief)------ 从设计上保护敏感字段
  2. 统一返回体 (ApiResponseT+ success())------ 前端一套解析逻辑
  3. response_model自动序列化 ------ 响应契约+Swagger文档自动生成
  4. 正确的 HTTP 状态码 ------201创建204删除、4xx 错误
  5. 响应头 ------X-Total-Count传递元信息
  6. 专用 Response 类 ------文件下载、流式推送、重定向各有其类
  7. 异常驱动错误处理 ------HTTPException/BusinessException+全局handler 统一格式

最终,接口不再是能跑就行,而是具备了生产环境应有的响应规范。

相关推荐
HuanYu1 小时前
PageHelper分页的原理
后端
于先生吖1 小时前
SpringBoot对接大模型开发AI命理测算系统:八字排盘与AI解析接口源码全解
人工智能·spring boot·后端
张不才2 小时前
一个静默吞数据的时间戳陷阱
后端
李少兄2 小时前
从原理到实战:Spring IoC/DI 核心知识体系与高频面试题全解
java·后端·spring
ServBay2 小时前
ServBay 1.30.0 更新:双平台引入 MCP 服务,AI 编程助手成为全栈本地运维
后端·ai编程
张不才2 小时前
分页查出来的数据总少几条?可能是 MyBatis 后置过滤的坑
后端
Windeal2 小时前
Agent ToolCall 循环怎么定制?PI Extension 与 DeepAgents Middleware 两条岔路深度对比
后端·openai
鱼人2 小时前
targets 包实战:R 语言数据分析流水线自动化管理方案
后端
时雨__2 小时前
一文搞懂 Python 并发:GIL、多线程/多进程/协程怎么选
后端