必须在await call_next(request)前用time.time()存入request.state.start_time,之后用time.time()-request.state.start_time计算耗时,取整毫秒输出;response.status_code可用,body不可读;须用basehttpmiddleware确保覆盖所有请求。

FastAPI中间件里怎么拿到请求开始时间
关键不是“存时间”,而是得在请求刚进来、还没进路由前就记下那一刻。用 time.time() 最直接,但别存在局部变量里——中间件函数执行完就丢了,得挂到 request.state 上才能传给后续逻辑。
常见错误是写成全局变量或模块级变量存时间,结果并发请求互相覆盖;或者用 datetime.now() 但没注意时区和精度,导致响应时间算出来负数或偏差大。
- 必须在
await call_next(request)前调用time.time() - 务必存到
request.state.start_time = time.time(),不能存到普通变量 - 避免用
datetime.utcnow(),它不保证单调递增,高并发下可能倒退
如何在FastAPI中间件里安全读取响应体并计算耗时
不能直接读 response.body,因为 FastAPI 的 Response 对象默认 body 是惰性生成的,且可能被流式处理(比如 StreamingResponse)。真要测耗时,只关心“从收到请求到返回 headers 的时间”,也就是 call_next() 返回的时刻减去开始时间即可。
想额外记录状态码或路径?可以从 response.status_code 和 request.url.path 拿,这两个字段在 call_next() 返回后就稳定可用。
立即学习“Python免费学习笔记(深入)”;
- 耗时 =
time.time() - request.state.start_time,放在await call_next(request)后立刻算 -
response.status_code可靠,但response.body多数情况是b''或不可读,别碰 - 如果用了
BackgroundTasks或异步写日志,确保不阻塞主响应流程
为什么用 Starlette 的 BaseHTTPMiddleware 而不是装饰器写法
装饰器方式(比如给每个路由加 @timing)看着简单,但漏掉所有 404、422、CORS 预检请求,也抓不到异常响应(比如未捕获的 HTTPException)。而中间件走的是 ASGI 生命周期底层,所有进入应用的请求都过一遍。
Starlette 的 BaseHTTPMiddleware 是 FastAPI 官方推荐基类,它自动处理了 request/response 的 scope 生命周期,比手写 ASGI callable 更稳;自己写 async def middleware(request, call_next): 也能用,但容易漏掉异常传播逻辑。
- 用
BaseHTTPMiddleware子类,重写dispatch方法,结构清晰不易错 - 别用
@app.middleware("http")装饰器配合手动 try/except 包裹call_next,异常时 response 可能为空 - 如果项目用了
HTTPSRedirectMiddleware或TrustedHostMiddleware,你的耗时中间件得排在它们之后才合理
日志里打印响应时间要不要带单位和小数位
要。不带单位就是裸数字,后期查问题时根本分不清是毫秒还是秒;小数位太多(比如保留 6 位)反而干扰判断,因为网络栈本身就有微秒级抖动,Python 的 time.time() 在 Linux 上通常只有 10ms 级精度。
实际线上建议统一用毫秒、整数,既对齐监控系统(如 Prometheus 的 _seconds 指标),又避免浮点误差。如果做 APM 追踪,再考虑纳秒级 time.perf_counter_ns(),但中间件里没必要。
- 输出格式推荐:
f"status={response.status_code} path={request.url.path} time_ms={int((end - start) * 1000)}" - 别用
round(..., 3)再转字符串,浮点舍入可能引入不可预期的 0.001 差异 - 如果日志量大,避免在中间件里做字符串拼接,先存数字,交由日志库格式化
事情说清了就结束。中间件看似简单,但时间戳的采集时机、response 字段的可用性边界、以及 ASGI 中间件链的执行顺序,三者稍一错位,统计出来的“响应时间”就完全失真。








