适用场景
这篇文章适用于使用 Python httpx 调用内部 HTTP 接口、第三方 API、网关或微服务时,线上偶发出现请求超时、任务堆积、接口吞吐下降的问题。常见场景包括:
- 定时任务批量调用接口;
- FastAPI、Django、Celery worker 中复用
httpx.Client或httpx.AsyncClient; - 并发抓取、批量同步、回调通知等高频 HTTP 调用;
- 调用方日志中出现
httpx.PoolTimeout、ReadTimeout、ConnectTimeout混杂的问题。
本文重点讨论 PoolTimeout。它通常不是目标接口一定不可用,而是调用方本地连接池没有可用连接,新的请求在等待连接时超过了 pool 超时时间。
现象描述
一次线上同步任务中,worker 数量增加后,接口失败率开始升高。业务日志里能看到类似错误:
httpx.PoolTimeout:
或者封装后的日志只显示:
request failed: timed out waiting for connection from pool
同时还有几个伴随现象:
- 目标接口服务端 QPS 并没有明显升高,CPU 和内存也正常;
- 调用方进程的线程或协程数量明显增加;
- 失败集中出现在批量任务高峰期;
- 降低并发后错误消失;
- 把
timeout简单调大后,失败率下降但任务耗时变长,根因没有消失。
可能原因
httpx 的超时不是一个单一值,通常可以拆成连接、读取、写入、连接池等待几个阶段:
connect:建立 TCP/TLS 连接的等待时间;read:等待服务端响应数据的时间;write:发送请求体的时间;pool:从连接池获取可用连接的等待时间。
当并发请求数大于连接池允许的最大连接数,或者已有连接长时间被慢请求占用,新请求就会等待连接池释放连接。如果等待时间超过 pool 超时,就会抛出 PoolTimeout。
常见根因包括:
- 连接池上限太小,无法覆盖实际并发;
- 每个请求都创建新的 client,导致连接复用失效;
- 响应体没有正确读取或关闭,连接无法及时归还连接池;
- 上游接口响应慢,连接被长期占用;
- 异步代码中无限制
gather,瞬间制造过高并发; - worker、线程池、Celery concurrency 调大后,没有同步调整 HTTP 连接池和限流策略;
- 没有区分
PoolTimeout、ReadTimeout、ConnectTimeout,误以为都是网络超时。
排查思路
1. 先确认异常类型
不要只记录 str(e),应记录异常类名、URL、耗时、并发批次等信息:
import logging
import time
logger = logging.getLogger(__name__)
def call_api(client, url: str) -> dict:
started = time.perf_counter()
try:
response = client.get(url)
response.raise_for_status()
return response.json()
except Exception as exc:
cost_ms = round((time.perf_counter() - started) * 1000, 2)
logger.exception(
"http request failed type=%s url=%s cost_ms=%s",
type(exc).__name__,
url,
cost_ms,
)
raise
关键看 type(exc).__name__:
PoolTimeout:本地连接池等待超时;ConnectTimeout:建立连接慢,可能是网络、DNS、TLS、目标端口不可达;ReadTimeout:连接已建立,但服务端迟迟不返回;HTTPStatusError:服务端返回了 4xx/5xx,不属于连接池超时。
2. 查看 client 是否被频繁创建
错误示例:
import httpx
def sync_one(item_id: int) -> dict:
with httpx.Client(timeout=5.0) as client:
return client.get(f"https://api.example.com/items/{item_id}").json()
如果这个函数在循环或高并发中被频繁调用,每次都会重新创建连接池,无法复用 keep-alive 连接,还会放大 TCP 建连、TLS 握手和端口消耗。
更合理的做法是在进程、任务批次或应用生命周期内复用 client:
import httpx
timeout = httpx.Timeout(connect=3.0, read=10.0, write=5.0, pool=2.0)
limits = httpx.Limits(max_connections=50, max_keepalive_connections=20)
with httpx.Client(timeout=timeout, limits=limits) as client:
for item_id in item_ids:
response = client.get(f"https://api.example.com/items/{item_id}")
response.raise_for_status()
这里两个参数需要结合实际并发评估:
max_connections:同一个 client 允许同时打开的最大连接数;max_keepalive_connections:允许保留复用的空闲连接数。
3. 检查响应是否被正确释放
如果使用 stream 模式,必须确保响应被关闭:
with client.stream("GET", url) as response:
response.raise_for_status()
for line in response.iter_lines():
handle_line(line)
不要写成下面这种形式后忘记关闭:
response = client.build_request("GET", url)
# 后续没有完整读取响应,也没有 close
对于普通 client.get(),httpx 会读取响应体后释放连接;但 stream 场景、手工 send 场景和异常分支更容易泄漏连接。
4. 对比并发数和连接池上限
假设一个 Celery worker 配置为:
celery -A app worker --concurrency=16
每个任务内部又同时发起 10 个 HTTP 请求,那么单进程瞬时并发可能接近:
16 * 10 = 160
如果 httpx.Limits(max_connections=50),峰值时一定会有大量请求等待连接池。此时要么提升连接池上限,要么降低任务内部并发,不能只改 timeout。
异步代码中也一样,下面这种无限制并发很容易打满连接池:
results = await asyncio.gather(
*(client.get(url) for url in urls)
)
更稳妥的写法是加信号量:
import asyncio
import httpx
timeout = httpx.Timeout(connect=3.0, read=10.0, write=5.0, pool=2.0)
limits = httpx.Limits(max_connections=50, max_keepalive_connections=20)
async def fetch_one(client: httpx.AsyncClient, sem: asyncio.Semaphore, url: str):
async with sem:
response = await client.get(url)
response.raise_for_status()
return response.json()
async def fetch_all(urls: list[str]):
sem = asyncio.Semaphore(30)
async with httpx.AsyncClient(timeout=timeout, limits=limits) as client:
return await asyncio.gather(
*(fetch_one(client, sem, url) for url in urls)
)
这里 Semaphore(30) 的含义是业务层最大并发 30,即使传入 1000 个 URL,也不会同时打出 1000 个请求。
常用定位命令
查看进程连接数量
Linux 上可以先找进程 PID:
pgrep -af "celery|gunicorn|uvicorn|python"
查看该进程到目标端口的连接状态:
ss -antp | grep 443 | grep "pid=12345" | awk '{print $1}' | sort | uniq -c
关键字段:
ESTAB:已建立连接,数量长期很高说明连接被占用;TIME-WAIT:短连接过多,可能是 client 频繁创建或 keep-alive 复用差;SYN-SENT:建连阶段卡住,优先排查网络、DNS、目标服务。
如果系统没有显示进程信息,可能需要 root 权限:
sudo ss -antp | grep "pid=12345"
查看文件描述符数量
ls /proc/12345/fd | wc -l
如果 fd 数量持续增长,结合 lsof 看是否有大量 socket:
lsof -p 12345 | grep TCP | head -50
fd 持续增长通常说明连接、文件或流没有正确关闭。
查看 DNS 和目标接口耗时
排查时可以从调用方机器执行:
time curl -o /dev/null -s -w "connect=%{time_connect} tls=%{time_appconnect} start_transfer=%{time_starttransfer} total=%{time_total}\n" https://api.example.com/health
关键字段:
time_connect:TCP 建连耗时;time_appconnect:TLS 握手完成耗时;time_starttransfer:首字节返回耗时;time_total:完整请求耗时。
如果这些指标都很低,但应用仍报 PoolTimeout,更应关注本地并发和连接池。
实践示例
某批量同步任务有 8 个 worker,每个 worker 内部对 200 个用户并发调用用户画像接口。代码简化如下:
async def sync_users(user_ids):
async with httpx.AsyncClient(timeout=5.0) as client:
tasks = [
client.get(f"https://profile.example.com/users/{user_id}")
for user_id in user_ids
]
return await asyncio.gather(*tasks)
问题点有三个:
- 单个 worker 一次性发起 200 个请求;
- 默认连接池配置没有按业务峰值明确设置;
timeout=5.0没有区分连接、读取和连接池等待阶段。
改造后:
import asyncio
import httpx
PROFILE_TIMEOUT = httpx.Timeout(
connect=3.0,
read=8.0,
write=3.0,
pool=1.5,
)
PROFILE_LIMITS = httpx.Limits(
max_connections=40,
max_keepalive_connections=20,
)
async def fetch_profile(
client: httpx.AsyncClient,
sem: asyncio.Semaphore,
user_id: int,
):
async with sem:
response = await client.get(
f"https://profile.example.com/users/{user_id}"
)
response.raise_for_status()
return response.json()
async def sync_users(user_ids: list[int]):
sem = asyncio.Semaphore(30)
async with httpx.AsyncClient(
timeout=PROFILE_TIMEOUT,
limits=PROFILE_LIMITS,
) as client:
return await asyncio.gather(
*(fetch_profile(client, sem, user_id) for user_id in user_ids)
)
这个改造的核心不是单纯把连接数调大,而是同时做了三件事:
- 明确连接池上限,避免默认值和实际并发不匹配;
- 使用信号量限制业务并发,避免瞬时请求洪峰;
- 拆分 timeout,方便从异常类型判断问题阶段。
修复方案
短期止血
- 降低批处理并发、worker concurrency 或异步 gather 数量;
- 临时调大
max_connections,让连接池容量覆盖真实并发; - 适当调大
pooltimeout,减少尖峰期误报; - 对非关键任务增加重试,但只对幂等请求启用;
- 对上游慢接口设置更合理的
readtimeout,避免连接长期被占用。
重试示例:
import time
import httpx
def get_with_retry(client: httpx.Client, url: str, retries: int = 2):
for attempt in range(retries + 1):
try:
response = client.get(url)
response.raise_for_status()
return response.json()
except (httpx.PoolTimeout, httpx.ReadTimeout) as exc:
if attempt >= retries:
raise
time.sleep(0.2 * (attempt + 1))
注意:重试会放大请求量。上游已经慢或过载时,应优先限流和降级,而不是盲目重试。
长期治理
- 在应用生命周期内复用
Client/AsyncClient; - 为不同上游设置独立 client,避免一个慢上游占满全局连接池;
- 给批处理任务增加并发上限和队列长度控制;
- 对核心上游增加耗时、状态码、异常类型维度的指标;
- 区分
PoolTimeout、ConnectTimeout、ReadTimeout告警; - 建立压测基线,记录不同并发下的连接数、P95/P99 耗时和错误率。
监控建议
至少记录以下指标:
http_client_requests_total{upstream,method,status}
http_client_request_duration_seconds_bucket{upstream,method}
http_client_errors_total{upstream,error_type}
http_client_inflight_requests{upstream}
如果暂时没有完整指标系统,也可以先在日志中打印结构化字段:
event=http_client_failed upstream=profile error_type=PoolTimeout cost_ms=1503 worker=sync-user
排查时重点看:
PoolTimeout是否集中在某个上游;- 错误是否只在批处理窗口出现;
inflight是否接近连接池上限;ReadTimeout是否先升高,随后引发PoolTimeout。
预防措施
- 不要在高频函数内部反复创建
httpx.Client; - 不要无限制并发调用外部接口;
- 不要把所有超时都设置成一个数字后就结束;
- stream 响应必须使用上下文管理器或显式关闭;
- worker 并发调整后,同步评估 HTTP 连接池、数据库连接池和上游限流;
- 对第三方 API 保留熔断、降级和重试上限;
- 对批量任务设置分批大小,例如每批 50 或 100 条,而不是一次性提交全部任务。
总结
httpx.PoolTimeout 的核心含义是:请求没有及时从本地连接池拿到可用连接。它经常由并发过高、连接池配置不匹配、响应未释放或上游慢请求占用连接引起。
排查时不要只把 timeout 调大,而应先确认异常类型,再检查 client 生命周期、连接池上限、业务并发、响应关闭和上游耗时。稳定的治理方案通常是:复用 client、限制并发、拆分 timeout、按上游隔离连接池,并把异常类型纳入监控。
Discussion
评论