
在 FastAPI/Starlette 中,使用 ContextVar 在中间件间共享请求上下文(如 request ID)时,若 BaseHTTPMiddleware 子类位于其他中间件之前,其设置的 ContextVar 值无法被后续中间件或 Uvicorn 访问日志器读取——根本原因在于 BaseHTTPMiddleware 的协程调度机制会创建新的 contextvars 上下文快照,导致“向下传播但不向上回传”。
在 fastapi/starlette 中,使用 contextvar 在中间件间共享请求上下文(如 request id)时,若 basehttpmiddleware 子类位于其他中间件之前,其设置的 contextvar 值无法被后续中间件或 uvicorn 访问日志器读取——根本原因在于 basehttpmiddleware 的协程调度机制会创建新的 contextvars 上下文快照,导致“向下传播但不向上回传”。
FastAPI 和 Starlette 默认推荐使用 BaseHTTPMiddleware 构建中间件,因其封装了请求/响应生命周期、自动处理异常和流式响应等便利特性。然而,该基类存在一个关键限制:它在调用 call_next() 时会通过 anyio.to_thread.run_sync 或类似机制切换执行上下文,从而导致 contextvars.ContextVar 的值无法跨 await 边界“回流”到父级上下文。这意味着:
- ✅
SetterMiddleware中req_id.set(...)设置的值,在call_next()内部(如路由处理器、依赖注入、Uvicorn 日志器)中可正常读取; - ❌ 但当
SetterMiddleware不是最外层中间件(即其后还有其他BaseHTTPMiddleware子类),后续中间件的dispatch()方法运行在新创建的 contextvars 上下文中,无法继承前序中间件设置的ContextVar值; - ❌ 更严重的是,Uvicorn 的访问日志(access log)由独立的 ASGI 生命周期钩子触发(如
on_complete回调),其执行时机晚于所有BaseHTTPMiddleware.dispatch(),且不在同一async def dispatch协程栈中,因此req_id.get()返回默认值(如None)。
这就是你观察到的现象:
- 当
SetterMiddleware是最后一个注册的中间件 → 它包裹了整个请求链,req_id对uvicorn.access日志器可见; - 当
FooMiddleware排在其后 →SetterMiddleware的ContextVar设置被隔离,FooMiddleware及更外层(含 Uvicorn 日志)无法感知。
✅ 正确解法:改用纯 ASGI 中间件(无 BaseHTTPMiddleware 继承)
绕过 BaseHTTPMiddleware 的上下文隔离问题,直接实现标准 ASGI 协议中间件——它完全复用原始 scope/receive/send,不引入额外的上下文切换层,确保 contextvars 全局(当前请求协程内)一致:
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.requests import Request
from contextvars import ContextVar
# 全局 ContextVar(务必在模块顶层定义)
req_id: ContextVar[str] = ContextVar("req_id", default=None)
class SetterMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
# 设置 ContextVar(对整个请求生命周期有效)
calculated_id = "1234567890"
req_id.set(calculated_id)
# 可选:同时存入 request.state(供 FastAPI 路由使用)
request = Request(scope, receive)
request.state.req_id = calculated_id
await self.app(scope, receive, send)
class FooMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
# 此处 req_id.get() 已能正确读取 SetterMiddleware 设置的值
print(f"FooMiddleware sees req_id: {req_id.get()}") # 输出: 1234567890
await self.app(scope, receive, send)
注册方式不变:
app = FastAPI() app.add_middleware(SetterMiddleware) # 注意:这里传入的是类,非实例 app.add_middleware(FooMiddleware)
⚠️ 关键注意事项
-
ContextVar必须在模块顶层定义:避免因导入顺序或作用域导致多实例; -
纯 ASGI 中间件需手动处理非 HTTP 协议(如 WebSocket):示例中已用
if scope["type"] != "http"过滤; -
request.state不再自动可用:Request(scope, receive)需显式构造,且request.state仅在当前Request实例内有效,不替代ContextVar的跨协程能力; -
日志 Filter 无需修改:
CustomFilter.filter()中req_id.get()将始终返回正确值,因 Uvicorn 访问日志与请求协程同属一个contextvars.Context; -
性能无损耗:纯 ASGI 中间件比
BaseHTTPMiddleware更轻量,无额外协程调度开销。
✅ 验证效果
启用上述中间件后,日志将稳定输出:
INFO 2025-04-14,15:02:28 [1234567890]| hello INFO 2025-04-14,15:02:28 [1234567890]| 127.0.0.1:52912 - "GET / HTTP/1.1" 200
无论中间件注册顺序如何,req_id 均能被所有环节(路由、依赖、自定义日志、Uvicorn 访问日志)一致读取——这才是 ContextVar 设计的本意:为异步请求提供真正隔离、可预测的上下文存储。
? 延伸建议:若需更高级的上下文管理(如自动注入 TraceID、用户身份),可结合
starlette-context的context对象(它内部也基于ContextVar),但务必确保其初始化中间件为纯 ASGI 形式,而非BaseHTTPMiddleware子类。
大量免费API接口:立即使用
涵盖生活服务API、金融科技API、企业工商API、等相关的API接口服务。免费API接口可安全、合规地连接上下游,为数据API应用能力赋能!











