适用场景
Python 服务或批处理脚本通过 httpx 调用第三方 HTTP API。流量上来后,应用日志开始出现 httpx.PoolTimeout,但目标接口的监控显示延迟正常;重启服务后短暂恢复,随后问题又出现。常见于 FastAPI/Django 的异步任务、数据同步程序和并发爬取工具。
本文以 httpx.AsyncClient 为例,说明如何确认连接池被耗尽、找出没有及时归还连接的调用路径,并用合理的超时、连接限制和并发闸门修复问题。
现象与原因
典型异常如下:
httpx.PoolTimeout:
这并不等同于“远端服务响应慢”。PoolTimeout 表示任务在指定的 pool 等待时间内,始终拿不到一个可用连接。连接不够用通常有四类原因:
- 每次请求都新建
AsyncClient,复用失效并制造大量短连接; - 使用
client.stream()后没有完整读取或关闭响应,连接无法回到池中; - 下游接口偶发慢请求,占用连接的时间远大于预期;
- 业务并发数远大于
max_connections,且没有背压。
先区分 ConnectTimeout、ReadTimeout 和 PoolTimeout。前两者发生在建立或读取某一条网络连接的过程;PoolTimeout 发生在请求真正发出之前,因此盲目调大 read 超时通常无效。
第一阶段:确认连接池压力与调用方式
不要先提高连接数。先在异常处记录目标主机、请求耗时、当前业务并发和异常类型,注意不要记录认证头和请求正文:
import asyncio
import logging
import time
import httpx
logger = logging.getLogger(__name__)
async def fetch(client: httpx.AsyncClient, url: str) -> dict:
started = time.monotonic()
try:
response = await client.get(url)
response.raise_for_status()
return response.json()
except httpx.TimeoutException as exc:
logger.warning(
"outbound_timeout type=%s host=%s elapsed_ms=%d tasks=%d",
type(exc).__name__,
httpx.URL(url).host,
(time.monotonic() - started) * 1000,
len(asyncio.all_tasks()),
)
raise
确认客户端是否被复用。下面的写法会让每个请求创建一套新连接池,不适合作为高频调用路径:
# 错误示例:不要在每次请求中创建客户端
async def bad_fetch(url: str) -> str:
async with httpx.AsyncClient() as client:
return (await client.get(url)).text
对于 Web 应用,应在应用生命周期内创建一个客户端并在关闭时统一释放。FastAPI 可使用 lifespan:
from contextlib import asynccontextmanager
from fastapi import FastAPI
import httpx
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient()
yield
await app.state.http.aclose()
app = FastAPI(lifespan=lifespan)
第二阶段:排查流式响应没有关闭
最隐蔽的问题是手动流式请求后忘记关闭响应。连接只有在响应被读完或显式 aclose() 后才可复用。
# 错误示例:异常或提前 return 时,响应可能一直占着连接
request = client.build_request("GET", url)
response = await client.send(request, stream=True)
if response.status_code != 200:
return None
使用上下文管理器可确保任何路径都释放连接:
async def download_head(client: httpx.AsyncClient, url: str) -> bytes:
async with client.stream("GET", url) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
return chunk
return b""
若必须把 Response 交给其他函数处理,使用 BackgroundTask(response.aclose) 或在 finally 中调用 await response.aclose(),并在代码审查中禁止裸露的 send(..., stream=True)。
修复方案:连接上限、超时与业务并发一起配置
连接数不是越大越好。先根据目标 API 的限流、单请求 P99 耗时和应用实例数确定总并发,再为每个实例设置连接池和业务信号量。下面配置允许单实例最多 40 条活跃连接、10 条空闲连接,并最多同时执行 32 个下游调用:
import asyncio
import httpx
limits = httpx.Limits(
max_connections=40,
max_keepalive_connections=10,
keepalive_expiry=30.0,
)
timeout = httpx.Timeout(
connect=2.0,
read=8.0,
write=8.0,
pool=1.0,
)
client = httpx.AsyncClient(limits=limits, timeout=timeout)
outbound_slots = asyncio.Semaphore(32)
async def fetch_json(url: str) -> dict:
async with outbound_slots:
response = await client.get(url)
response.raise_for_status()
return response.json()
max_connections 是整个客户端的并发连接上限;max_keepalive_connections 控制可保留的空闲连接数;pool 只限制等待连接的时间。信号量应小于等于连接上限,为重试、健康检查等其他调用留出余量。
如果下游按域名隔离配额,不要让一个慢域名占满公共池。可以为不同依赖创建独立客户端,或在业务层为每个依赖使用独立信号量;不要把它们拆成“每次请求新建客户端”。
定位示例:慢请求放大为池耗尽
假设单实例有 40 条连接,正常请求耗时 100 ms,理论吞吐约为每秒 400 个请求。某次下游抖动将 P99 拉到 5 秒,同样 40 条连接只能支撑约每秒 8 个完成请求。若上游仍以每秒 100 个任务提交,请求会排队,随后在 1 秒 pool 超时后集中报错。
因此应同时观察:
- 下游依赖的 P50/P95/P99 耗时和状态码;
PoolTimeout每分钟增量与业务信号量等待时长;- 每个实例的入站并发、重试次数和队列长度;
- DNS、TLS 和连接建立耗时,判断慢点是否在网络侧。
对幂等 GET 请求可使用有限退避重试,但不要在 PoolTimeout 后立即无限重试,否则会进一步扩大队列。一个简单原则是:最多重试 2 次,带随机抖动,并受同一个信号量约束。
验证与预防
发布修复前,在预发环境制造比日常峰值更高的并发,并让模拟下游延迟 3~5 秒。验收时不只看“没有异常”,还要确认等待中的任务数会回落、下游恢复后吞吐能稳定恢复,且客户端在应用退出时执行了 aclose()。
建议增加以下监控:下游请求的耗时/状态码/异常类型,业务信号量等待时间,重试次数,以及每个依赖的并发上限配置版本。告警阈值可设为连续 5 分钟出现 PoolTimeout,或 P99 耗时超过连接池设计假设。
总结
httpx.PoolTimeout 的核心是“没有可借出的连接”,而不是单纯的网络超时。先保证客户端生命周期正确、流式响应必定关闭,再用连接池参数约束资源,并以业务信号量给下游调用施加背压。这样即使依赖变慢,故障也会被限制在可观测、可恢复的范围内。
Discussion
评论