适用场景

Python 服务或批处理脚本通过 httpx 调用第三方 HTTP API。流量上来后,应用日志开始出现 httpx.PoolTimeout,但目标接口的监控显示延迟正常;重启服务后短暂恢复,随后问题又出现。常见于 FastAPI/Django 的异步任务、数据同步程序和并发爬取工具。

本文以 httpx.AsyncClient 为例,说明如何确认连接池被耗尽、找出没有及时归还连接的调用路径,并用合理的超时、连接限制和并发闸门修复问题。

现象与原因

典型异常如下:

httpx.PoolTimeout: 

这并不等同于“远端服务响应慢”。PoolTimeout 表示任务在指定的 pool 等待时间内,始终拿不到一个可用连接。连接不够用通常有四类原因:

  • 每次请求都新建 AsyncClient,复用失效并制造大量短连接;
  • 使用 client.stream() 后没有完整读取或关闭响应,连接无法回到池中;
  • 下游接口偶发慢请求,占用连接的时间远大于预期;
  • 业务并发数远大于 max_connections,且没有背压。

先区分 ConnectTimeoutReadTimeoutPoolTimeout。前两者发生在建立或读取某一条网络连接的过程;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 的核心是“没有可借出的连接”,而不是单纯的网络超时。先保证客户端生命周期正确、流式响应必定关闭,再用连接池参数约束资源,并以业务信号量给下游调用施加背压。这样即使依赖变慢,故障也会被限制在可观测、可恢复的范围内。