FastAPI-04.中间件异常与日志

本系列:00 导读 · 01 心智模型 · 02 路由 · 03 依赖注入 · 04 中间件异常日志(本文) · 05 异步 · 06 鉴权 · 07 测试 · 08 实战

行文:T3 辨析篇 | 本篇方法:交错练习 + 排错 | 全链路心智01 §4 同源


0. 完整执行逻辑流程图(任务流转)

读中间件 / 异常 / 日志前,先把一次请求会经过的全部组件摆上台面。细节原理见 01;本节省「在管道哪一层动手」。

0.1 两层时钟(先分清)

尺度 触发 典型组件
进程 / worker 启动、关闭 lifespan、连接池、模型加载
单次 HTTP 请求 每个请求一次 中间件洋葱 → 路由 → 依赖 → 校验 → 端点 → 响应 →(可选)BackgroundTasks

lifespan 插入每个请求中间;WebSocket / mount 子应用是旁路,见 §0.5。

0.2 漫画总览

全链路请求生命周期(科普漫画)

0.3 组件清单(可能出现的架构件)

组件 职责
网络 客户端、TCP、反向代理(可选) 字节进出;TLS/负载均衡常在 uvicorn 外
ASGI 服务器 uvicorn 解析 HTTP/WS → 调应用 app(scope, receive, send)
应用壳 FastAPI / Starlette 实现 ASGI callable;叠路由与中间件
横切 中间件洋葱(含 CORS 等) call_next 前/后加工;可短路不进路由
错误管道 ExceptionMiddleware 概念层 + exception_handler 把异常变成 Response
路由 APIRouter / 路由表 按 method+path 匹配;否则 404/405
注入 Depends(含安全依赖) 解析用户、DB session 等
校验 Pydantic(Path/Query/Body) 失败 → 422(可定制 handler)
业务 端点 async def / def(后者常进线程池) 真正业务
出站整形 response_model、状态码、Response 子类 过滤字段、定成功码
后置 BackgroundTasks 响应发出后同进程轻量活
协议回写 send 事件 http.response.start / body(或流式多段)

段末注释中间件洋葱指请求从外层进、响应从内层出,像剥洋葱;后 add_middleware 的一般更靠外。后文「洋葱」即此意。

0.4 HTTP 主路径(精确流转)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
flowchart TD
C[客户端] --> N[TCP / 可选反向代理]
N --> U[uvicorn ASGI 服务器]
U -->|构造 scope receive send| APP[FastAPI ASGI 应用]

subgraph life["进程时钟 · 非每请求"]
L1[lifespan 启动] -.-> APP
APP -.-> L2[lifespan 关闭]
end

APP --> MW_IN[中间件洋葱 · 请求下行<br/>外层 → 内层 · 前半段 / 可直接 return]

MW_IN --> RT{路由匹配<br/>method + path}
RT -->|否| E404[404 / 405 Response]
RT -->|是| DEP[解析 Depends 依赖树]
DEP --> VAL[Path / Query / Body 校验]
VAL -->|失败| E422[RequestValidationError → 422 handler]
VAL -->|通过| EP{端点}
EP -->|async def| LOOP[事件循环直接 await]
EP -->|def 同步| POOL[线程池执行]
LOOP --> BIZ[业务逻辑]
POOL --> BIZ
BIZ --> RM[response_model / 状态码 / Response]
RM --> RESP[Response 对象]
E404 --> MW_OUT
E422 --> MW_OUT
RESP --> MW_OUT[中间件洋葱 · 响应上行<br/>内层 → 外层 · 后半段]

BIZ -.->|raise HTTPException| EH[exception_handler → Response]
BIZ -.->|未捕获异常| EH500[500 handler / 默认 500]
EH --> MW_OUT
EH500 --> MW_OUT

MW_OUT --> SEND[ASGI send 事件]
SEND --> U
U --> C

RESP -.->|可选| BG[BackgroundTasks<br/>响应后同进程]

读图要点

  1. 中间件的「前半段」在进路由前;「后半段」在拿到 Response 之后——对应 await call_next(request) 两侧。
  2. 依赖、校验、端点都在 call_next 之内
  3. HTTPException / 校验异常通常在进入响应上行前被转成 Response,仍会经过外层中间件后半段(除非中间件自身短路)。

0.5 旁路与变体(完整架构覆盖)

变体 流转差异
中间件短路 前半段直接 return Response,不调 call_next → 无路由/无依赖
CORS 预检 OPTIONS 常在最外层 CORSMiddleware 直接应答
WebSocket scope["type"]=="websocket";长连接收发,不是「一次 Response」模型
StreamingResponse / SSE 仍走中间件+路由;send 多次 body 块
app.mount 子应用 前缀匹配后把 scope 交给另一个 ASGI 应用(可另有中间件栈)
多 worker 每进程独立 lifespan + 独立事件循环;请求粘在某一个 worker

洋葱顺序放大(请求 ↓ / 响应 ↑):

1
2
3
4
5
6
7
8
请求 ↓                          响应 ↑
最外层中间件(后 add 的,如 CORS)
→ 用户 http 中间件
→ (框架异常转响应层)
→ 路由 → Depends → 校验 → 端点
← Response / handler 生成的 Response
← 用户中间件后半段(改 header、记耗时)
← 最外层后半段

1. 易混对照表

对比 A B 怎么选
横切位置 中间件 Depends 要改每个请求的 request/response 流 → 中间件;要注入业务对象/用户 → Depends
错误出口 HTTPException 全局 exception_handler 业务可预期错误用前者;统一 JSON 形状、未捕获异常用后者
校验失败 422 默认 自定义 validation handler 要友好错误码/字段格式 → 自定义 handler
日志粒度 中间件记 access 业务 logger access 记路径/耗时;业务记领域事件
执行顺序 中间件 洋葱模型 依赖在进路由前 中间件包在最外;CORS 通常最外一层

段末注释CORS(Cross-Origin Resource Sharing,跨源资源共享)是浏览器限制下,服务端通过响应头允许前端跨域访问的机制。


2. 中间件组块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import time
import uuid

from fastapi import FastAPI, Request
from starlette.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)


@app.middleware("http")
async def request_context(request: Request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
response.headers["X-Request-ID"] = request_id
response.headers["X-Process-Time"] = f"{elapsed_ms:.2f}ms"
return response
  • 洋葱:后 add_middleware 的在外层(先收到请求、最后送出响应)。
  • 误用:在中间件里做 DB 查询业务——应放 service + Depends。

3. 异常处理组块

3.1 业务可预期:HTTPException

1
2
3
4
5
6
7
from fastapi import HTTPException, status


raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)

3.2 统一未捕获异常

1
2
3
4
5
6
7
8
9
10
11
from fastapi import Request
from fastapi.responses import JSONResponse


@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
# 生产环境勿把 str(exc) 直接返回客户端
return JSONResponse(
status_code=500,
content={"detail": "Internal server error", "path": request.url.path},
)

3.3 校验错误 422 定制

1
2
3
4
5
6
7
8
9
from fastapi.exceptions import RequestValidationError


@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={"detail": exc.errors(), "body": exc.body},
)

4. 日志组块

1
2
3
4
5
6
7
8
9
10
11
12
import logging

logger = logging.getLogger("app")

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)


# 在中间件或路由里
logger.info("item_created id=%s user=%s", item_id, user.id)
记什么 放哪 示例字段
每条 HTTP 中间件 method, path, status, ms, request_id
业务事件 service action, entity_id, outcome
异常栈 exception handler logger.exception(...)

误用print 调试上线——无级别、无聚合,难与 access 日志关联。


5. 交错练习题(打乱类型)

# 场景 应选 理由
Q1 所有响应加 X-Request-ID 中间件 横切 response
Q2 从 JWT 解析当前用户 Depends 注入路由参数
Q3 资源不存在返回 404 JSON HTTPException 业务分支
Q4 未捕获异常统一 500 形状 exception_handler 全局兜底
Q5 允许前端 localhost:3000 跨域 CORSMiddleware 浏览器策略
Q6 Pydantic 校验失败要固定错误格式 validation handler 422 定制
Q7 记录每个接口耗时 中间件 与业务解耦
Q8 同一 router 要求 API Key APIRouter(dependencies=...) 路由级 Depends(见 03)

6. 康奈尔排错栏(错误 → 现象 → 根因 → 修法)

错误用例 现象 根因 修法
中间件里 return 不调 call_next 所有路由 404/无响应 请求未进入应用 必须 response = await call_next(request)
CORS 加在最内层 浏览器仍报 CORS 预检未过外层 CORSMiddleware 尽量最先 add(最后注册=最外)
HTTPException(500)try/except Exception 客户端收不到预期 body 被全局 handler 吃掉 区分 HTTPExceptionException
validation handler 返回非 JSON /docs 调试异常 响应类型不对 JSONResponse
日志无 request_id 多请求难关联 未贯穿中间件 中间件写入 header + logger extra

7. 15 分钟验收骨架

在新 main.py 一次性接入:

1
2
3
4
5
6
7
# 检查清单(打勾即过关)
# [ ] CORSMiddleware(若需前端)
# [ ] http 中间件:request_id + 耗时头
# [ ] RequestValidationError handler
# [ ] HTTPException 保持默认或统一包装
# [ ] Exception → 500 JSON(不泄露栈)
# [ ] logging.basicConfig + 中间件一条 access 级 info

curl -i 与故意传错 body 触发 422/500 各一次。


8. 闪卡候选

正面 背面
中间件 vs Depends? 改请求/响应管道 vs 注入参数
洋葱模型谁先收到请求? 最后 add_middleware 的最外层
422 谁抛出? FastAPI/Pydantic 请求校验
生产 500 响应能带 str(exc) 吗? 不应;记日志,对外通用文案

小结

  • 先有全链路:中间件包在路由外;Depends/校验/端点在 call_next 内;异常多在出洋葱前变成 Response(见 §0)。
  • 中间件管管道;Depends管注入;HTTPException管预期错误;handler管兜底与 422 形状。
  • 下一篇 05 异步、后台任务与流式:弄清「写了 async 为什么还卡」。
-------------本文结束感谢您的阅读-------------