适用场景

这篇文章适用于使用 Python httpx 调用内部 HTTP 接口、第三方 API、网关或微服务时,线上偶发出现请求超时、任务堆积、接口吞吐下降的问题。常见场景包括:

  • 定时任务批量调用接口;
  • FastAPI、Django、Celery worker 中复用 httpx.Clienthttpx.AsyncClient
  • 并发抓取、批量同步、回调通知等高频 HTTP 调用;
  • 调用方日志中出现 httpx.PoolTimeoutReadTimeoutConnectTimeout 混杂的问题。

本文重点讨论 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 连接池和限流策略;
  • 没有区分 PoolTimeoutReadTimeoutConnectTimeout,误以为都是网络超时。

排查思路

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,让连接池容量覆盖真实并发;
  • 适当调大 pool timeout,减少尖峰期误报;
  • 对非关键任务增加重试,但只对幂等请求启用;
  • 对上游慢接口设置更合理的 read timeout,避免连接长期被占用。

重试示例:

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,避免一个慢上游占满全局连接池;
  • 给批处理任务增加并发上限和队列长度控制;
  • 对核心上游增加耗时、状态码、异常类型维度的指标;
  • 区分 PoolTimeoutConnectTimeoutReadTimeout 告警;
  • 建立压测基线,记录不同并发下的连接数、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、按上游隔离连接池,并把异常类型纳入监控。