Python 基础体系 · 第 60/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python HTTP 客户端:连接池、超时、重试、流式和 TLS

HTTP 客户端不是“调用一个 URL 并读取 JSON”这么简单。一次请求至少可能经历 DNS 解析、TCP 建连、TLS 握手、连接池排队、发送请求头、上传请求体、等待响应头、持续读取响应体,以及关闭或复用连接等阶段。

如果这些阶段没有被分别建模,工程中常见的问题就很难解释:

  • 请求偶尔卡住,但设置了超时;
  • 并发一上升,延迟突然变高;
  • 重试后出现重复扣款或重复创建资源;
  • 读取大文件时内存暴涨;
  • HTTPS 在开发环境正常,在生产环境证书校验失败;
  • FastAPI 中使用异步客户端,却因为客户端生命周期错误导致连接池失效。

本文以 Python 3.14 为运行环境,使用标准库解释底层机制,并以 HTTPX 展示实际工程中的同步和异步写法。HTTPX 不是 Python 标准库的一部分,需要单独安装;它提供同步、异步、HTTP/1.1、HTTP/2、连接池、流式响应和 TLS 配置等能力。(python-httpx.org)


一、先建立完整的请求模型

1. 一次 HTTP 请求经过哪些阶段

把客户端请求抽象成以下状态:

准备请求
  │
  ├─ 连接池中有可复用连接 ─────────────┐
  │                                   │
  └─ 没有可用连接                       │
        │                              │
        ├─ 等待连接池槽位               │
        ├─ DNS 解析                     │
        ├─ TCP connect                  │
        ├─ TLS handshake(HTTPS)       │
        └─ 建立协议连接                 │
                                       ▼
                              发送请求头和请求体
                                       │
                                       ▼
                              接收响应头
                                       │
                                       ▼
                              读取响应体
                                       │
                     ┌─────────────────┴─────────────────┐
                     │                                   │
                完整读取并复用                       出错或关闭
                     │                                   │
                     ▼                                   ▼
                 归还连接池                         关闭连接

这里的“连接”不是一个抽象的 URL,而是底层 TCP 连接,HTTPS 下还包括该 TCP 连接之上的 TLS 会话。

对于 HTTP/1.1,一条连接在响应体尚未读取完之前,通常不能安全地交给另一个请求使用。于是:

连接池中的连接数 ≠ 当前正在创建的请求数

更准确地说:

  • max_connections 限制同时占用的连接总数;
  • max_keepalive_connections 限制空闲、可复用连接的数量;
  • 正在读取响应体的请求会继续占用连接;
  • 响应体没有读取完就放弃,客户端通常只能关闭连接,而不能复用它。

HTTPX 的 Client 会复用底层 TCP 连接,而每次直接调用 httpx.get() 这样的顶层 API 则不会像长期存在的 Client 一样提供连接池复用。(python-httpx.org)

2. HTTP、TCP、TLS 的分层关系

HTTP 客户端通常位于以下层次之上:

应用代码
  │  JSON、状态码、分页、幂等键
HTTP
  │  请求行、响应头、响应体
TLS(HTTPS 时存在)
  │  加密、身份认证、完整性保护
TCP
  │  可靠、有序的字节流
IP
  │  寻址和路由

TCP 提供的是字节流,不是消息。HTTP/1.1 需要依靠请求头、Content-Length、分块传输编码等机制判断一条消息的边界;HTTP 客户端负责把这些字节流解释成请求和响应。

TLS 也不负责定义 HTTP。TLS 在可靠的字节流之上建立安全通道,提供服务器认证、机密性和完整性;握手阶段协商参数并建立密钥,后续记录协议使用这些密钥保护应用数据。(rfc-editor.org)


二、标准库能做什么,HTTPX 解决什么问题

1. 使用 http.client 直接复用连接

Python 标准库的 http.client 是低级 HTTP 客户端,urllib.request 也通过它处理 HTTP 和 HTTPS。(docs.python.org)

最底层的连接复用可以这样观察:

from http.client import HTTPSConnection

conn = HTTPSConnection("example.com", timeout=5)

try:
    conn.request("GET", "/")
    response = conn.getresponse()
    body = response.read()  # 必须读完,才能尝试复用连接

    print(response.status)
    print(body[:100])

    conn.request("GET", "/")
    response = conn.getresponse()
    print(response.status)
    response.read()
finally:
    conn.close()

这个例子中,第二次请求有机会复用第一次请求创建的连接。关键条件是:

response.read()

如果响应体没有读完,客户端不知道连接中的字节是否已经属于下一条响应,也不能安全地把连接交给后续请求。Python 文档明确要求:在同一个 HTTPConnection 上发起下一次请求前,应先读完整个响应,或者关闭连接。(docs.python.org)

2. 为什么工程中通常使用客户端对象

低级 API 需要调用者自己处理:

  • 不同主机的连接管理;
  • 并发访问时的锁和连接分配;
  • 空闲连接回收;
  • 连接池大小;
  • 超时分类;
  • 流式响应关闭;
  • 重试和退避;
  • 代理、TLS 和 HTTP/2。

HTTPX 将这些能力封装到 ClientAsyncClient 中。工程代码中,客户端通常应当拥有比单次函数调用更长的生命周期:

import httpx

with httpx.Client(base_url="https://api.example.com") as client:
    response = client.get("/users")
    response.raise_for_status()
    users = response.json()

这里的 with 不只是语法简化,它保证退出作用域时关闭连接池。长期运行的服务可以把客户端放在应用生命周期中,而不是每次请求都创建一个新客户端。

错误的写法是把客户端放进热循环:

for user_id in user_ids:
    with httpx.Client() as client:
        client.get(f"https://api.example.com/users/{user_id}")

这会反复创建和销毁连接池,连接复用的价值基本消失。HTTPX 的异步文档也特别提醒,不要在热循环中不断创建 AsyncClient,否则无法获得有效的连接池复用。(python-httpx.org)


三、连接池:复用连接,而不是无限增加并发

1. 连接池中的三个数量

以 HTTPX 为例:

import httpx

limits = httpx.Limits(
    max_connections=100,
    max_keepalive_connections=20,
    keepalive_expiry=5.0,
)

client = httpx.Client(limits=limits)

三个参数含义不同:

参数 含义
max_connections 同时允许占用的连接总数
max_keepalive_connections 空闲连接最多保留多少条
keepalive_expiry 空闲连接保留多久

HTTPX 当前文档给出的默认值是 max_connections=100max_keepalive_connections=20keepalive_expiry=5 秒;实际使用时应以所安装版本的文档和运行行为为准。(python-httpx.org)

可以用一个简单模型理解连接池:

设:

  • C 为最大连接数;
  • A 为当前正在使用的连接数;
  • K 为池中空闲可复用连接数;
  • W 为正在等待连接的请求数。

则通常有:

0 ≤ A ≤ C
0 ≤ K ≤ max_keepalive_connections
W > 0  ⇔  没有空闲连接且 A 已达到 C

并发请求数超过 C 时,并不是自动创建更多连接,而是进入等待状态。等待本身也必须有超时,否则连接池耗尽时请求可能长期阻塞。

2. 连接池不是全局限流器

连接池限制的是连接资源,不一定等价于业务并发限制。

例如:

最大连接数 C = 10
每个请求平均持续时间 S = 2 秒

在理想情况下,吞吐上限大致受:

吞吐量 ≈ C / S = 10 / 2 = 5 请求/秒

影响。

这个公式只是粗略估算,因为真实请求还会受到服务端处理、网络带宽、响应体大小、连接复用、HTTP/2 多路复用等因素影响。但它能解释一个现象:把连接数从 10 增加到 100,不一定让系统变快,可能只是把压力转移到:

  • 本机文件描述符;
  • NAT 表;
  • 服务端连接数;
  • 数据库连接;
  • CPU 上下文切换;
  • 连接池等待和超时。

3. HTTP/1.1 与 HTTP/2 的差异

HTTP/1.1 中,一条 TCP 连接通常按请求响应顺序处理;多个并发请求往往需要多个连接。

HTTP/2 支持在一条连接上复用多个 stream,因此:

HTTP/1.1:并发请求通常消耗多个 TCP 连接
HTTP/2:多个请求可以共享一条 TCP 连接

但这不意味着连接数限制可以随意设为 1。HTTP/2 仍然受服务端最大并发 stream、流控窗口、带宽和单连接队头阻塞等因素影响。HTTPX 同时支持 HTTP/1.1 和 HTTP/2,但是否实际使用 HTTP/2 取决于客户端配置和服务端协商。(python-httpx.org)


四、超时:不是一个数字,而是一组阶段预算

1. 四种超时

HTTPX 将超时分成四类:

  • connect timeout:建立连接时最多等待多久;
  • read timeout:等待下一块响应数据最多多久;
  • write timeout:发送下一块请求数据最多多久;
  • pool timeout:从连接池获取连接最多等待多久。

如果连接池已满,触发的是 PoolTimeout,而不是 ConnectTimeout。如果连接已建立但服务端长时间不返回数据,触发的是 ReadTimeout。(python-httpx.org)

import httpx

timeout = httpx.Timeout(
    connect=3.0,
    read=10.0,
    write=10.0,
    pool=2.0,
)

with httpx.Client(timeout=timeout) as client:
    response = client.get("https://api.example.com/items")
    response.raise_for_status()

这段配置表达的是:

连接建立最多等 3 秒
每次读取数据最多等 10 秒
每次写入数据最多等 10 秒
等待池中连接最多等 2 秒

2. 读取超时不是整个响应的总时长

假设:

read timeout = 5 秒

服务端每隔 4 秒发送一个字节,客户端可能一直不超时,因为每次等待下一块数据都没有超过 5 秒。

因此:

read timeout ≠ 整个请求的 deadline

如果业务要求“整个请求最多只能运行 30 秒”,还需要在应用层设置总截止时间:

import time
import httpx

deadline = time.monotonic() + 30.0

try:
    with httpx.Client(timeout=httpx.Timeout(5.0)) as client:
        response = client.get("https://api.example.com/large-result")
        response.raise_for_status()
except httpx.TimeoutException as exc:
    print(f"阶段超时: {type(exc).__name__}")

在更复杂的重试场景中,应让每一次重试共享同一个总预算,而不是每次都重新获得完整的 30 秒:

总预算 T = 30 秒
第 1 次请求消耗 8 秒
退避消耗 2 秒
第 2 次请求最多只能再消耗 20 秒

否则:

每次请求 30 秒 × 3 次 = 最坏 90 秒

会超过调用方对整个操作的预期。

3. 为什么不能无限关闭超时

下面的代码会允许网络操作永久等待:

import httpx

with httpx.Client(timeout=None) as client:
    client.get("https://api.example.com")

HTTPX 默认会对网络不活动进行超时控制,默认网络不活动时间为 5 秒;可以通过 timeout 修改或显式关闭。(python-httpx.org)

timeout=None 只适合非常明确的场景,例如:

  • 由外部 watchdog 控制生命周期;
  • 专门处理长连接;
  • 上层有更严格的总 deadline;
  • 能够正确取消任务和释放连接。

普通 API 调用关闭超时,会把单个异常请求变成长期占用连接池槽位的请求,最终表现为大量 PoolTimeout


五、重试:先判断“请求是否可能已经成功”

1. 网络错误不等于服务端没有执行

考虑以下时序:

客户端 ── POST 创建订单 ──> 服务端
客户端 <── 服务端已写入订单
网络连接断开
客户端没有收到响应

客户端看到的是连接错误,但服务端可能已经成功执行了请求。如果客户端直接重试:

第一次:订单已创建,但响应丢失
第二次:再次创建订单

就可能产生重复订单。

因此,重试前必须区分两个问题:

  1. 请求是否可以安全重复;
  2. 第一次请求是否可能已经被服务端应用。

HTTP 规范将幂等方法定义为:重复执行多个相同请求,其预期的服务器效果与执行一次相同。幂等性之所以重要,是因为客户端可能在无法读取响应时自动重试;对于非幂等请求,客户端不应在没有额外保证的情况下自动重试。(rfc-editor.org)

2. 方法名不等于业务幂等性

常见方法的语义倾向如下:

方法 通常是否幂等 说明
GET 获取资源,不应产生业务副作用
HEAD 获取元信息
PUT 将资源设置为指定表示
DELETE 删除目标资源,重复删除通常仍是删除状态
POST 通常否 可能每次创建新资源
PATCH 取决于设计 可能是增量修改,也可能是幂等设置

但方法名只是默认语义。一个 POST /payments 可以通过幂等键设计成业务幂等:

POST /payments
Idempotency-Key: 2c5e3c4d-...

服务端需要持久化这个键与最终结果的映射:

第一次收到 key=K:
  执行支付
  保存 K -> 支付结果

再次收到 key=K:
  不再次扣款
  返回之前保存的结果

只有服务端真的实现了这种语义,客户端才有理由对该 POST 进行受控重试。

3. 哪些错误值得重试

通常可以考虑重试:

  • 连接建立失败;
  • 连接建立超时;
  • 临时 DNS 或网络错误;
  • 502 Bad Gateway
  • 503 Service Unavailable
  • 504 Gateway Timeout
  • 明确由服务端约定为临时故障的响应。

通常不应盲目重试:

  • 400 Bad Request
  • 401 Unauthorized
  • 403 Forbidden
  • 404 Not Found
  • 参数校验错误;
  • TLS 证书校验失败;
  • 请求体不可重复读取;
  • 非幂等操作没有幂等键。

HTTP 503 响应可以携带 Retry-After,表示客户端应等待多久再发起后续请求;该字段可以是秒数,也可以是 HTTP 日期。(rfc-editor.org)

4. 指数退避和抖动

简单的指数退避可以写成:

delay_n = min(cap, base × 2^n)

其中:

  • n 是已经失败的重试次数,从 0 开始;
  • base 是基础等待时间;
  • cap 是最大等待上限。

例如:

base = 0.5 秒
cap = 8 秒

第 1 次重试:0.5 秒
第 2 次重试:1 秒
第 3 次重试:2 秒
第 4 次重试:4 秒
第 5 次重试:8 秒

如果所有客户端同时失败并按照完全相同的时间重试,会形成“惊群”。因此应加入随机抖动:

delay = random(0, min(cap, base × 2^n))

下面是一个仅对幂等请求和临时状态进行重试的同步示例:

import random
import time

import httpx


RETRYABLE_STATUS = {502, 503, 504}


def get_with_retry(
    client: httpx.Client,
    url: str,
    *,
    attempts: int = 3,
) -> httpx.Response:
    last_error: Exception | None = None

    for retry_index in range(attempts):
        try:
            response = client.get(url)

            if response.status_code not in RETRYABLE_STATUS:
                response.raise_for_status()
                return response

            # 先读取或关闭当前响应,避免连接被长期占用。
            retry_after = response.headers.get("Retry-After")
            response.close()

            if retry_index == attempts - 1:
                raise httpx.HTTPStatusError(
                    f"retryable status remains: {response.status_code}",
                    request=response.request,
                    response=response,
                )

            if retry_after and retry_after.isdigit():
                delay = min(float(retry_after), 10.0)
            else:
                delay = random.uniform(
                    0.0,
                    min(10.0, 0.5 * (2**retry_index)),
                )

            time.sleep(delay)

        except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
            last_error = exc

            if retry_index == attempts - 1:
                raise

            delay = random.uniform(
                0.0,
                min(10.0, 0.5 * (2**retry_index)),
            )
            time.sleep(delay)

    raise RuntimeError("unreachable") from last_error

这个示例只对 GET 使用自动重试,因此不涉及请求体重复发送。HTTPX 的 HTTPTransport(retries=n) 只覆盖连接错误和连接超时;如果还要根据 503、读取错误或其他状态码重试,需要在更高层实现策略,或使用专门的重试库。(python-httpx.org)

5. 重试的真实边界

下面这些情况不能简单地套用“失败就重试”:

响应已经返回,但业务处理失败

HTTP 200
响应 JSON:{"status": "processing"}

这不是传输失败,而是业务状态未完成。应该根据业务协议轮询、订阅事件或查询状态,而不是重发原请求。

请求体不可重复读取

def body():
    yield from open("large-file.bin", "rb")

如果第一次发送已经消耗了部分生成器,第二次重试时可能没有完整请求体。可重试请求必须具备:

  • 可重复读取的内存数据;
  • 可重新打开的文件;
  • 可重新生成且结果一致的请求体;
  • 或者明确的断点续传协议。

超时发生在响应阶段

ReadTimeout 只能说明客户端在某个读取阶段没有及时收到数据,不能说明服务端没有执行请求。对 POST 的读取超时尤其危险,仍然需要幂等键或状态查询。


六、流式请求和响应:让内存、连接和处理速度保持一致

1. 非流式读取的内存模型

如果响应体大小为 N 字节,非流式代码通常会把完整响应体读入内存:

import httpx

with httpx.Client() as client:
    response = client.get("https://example.com/archive.zip")
    response.raise_for_status()
    data = response.content

粗略地说,进程至少需要为响应体保留接近 N 的内存,解析过程还可能产生额外副本:

网络缓冲 + response.content + 解压缓冲 + 业务对象

如果 N = 2 GiB,显然不适合一次性读入内存。

2. 流式响应

HTTPX 提供上下文管理器来保证流式响应最终关闭:

import httpx

with httpx.Client() as client:
    with client.stream(
        "GET",
        "https://example.com/archive.zip",
    ) as response:
        response.raise_for_status()

        with open("archive.zip", "wb") as output:
            for chunk in response.iter_bytes(chunk_size=64 * 1024):
                output.write(chunk)

数据流路径是:

服务端
  │
  ▼
socket 接收缓冲
  │
  ▼
HTTPX 迭代器
  │
  ▼
应用每次读取 64 KiB
  │
  ▼
文件系统

iter_bytes() 每次只交付一部分响应体,因此应用不必同时保存整个文件。更重要的是,with client.stream(...) 退出时会关闭或释放响应,避免连接池中的连接永久处于占用状态。

异步版本如下:

import asyncio
from pathlib import Path

import httpx


async def download(url: str, destination: Path) -> None:
    timeout = httpx.Timeout(
        connect=5.0,
        read=30.0,
        write=30.0,
        pool=5.0,
    )

    async with httpx.AsyncClient(timeout=timeout) as client:
        async with client.stream("GET", url) as response:
            response.raise_for_status()

            with destination.open("wb") as output:
                async for chunk in response.aiter_bytes(64 * 1024):
                    output.write(chunk)
                    await asyncio.sleep(0)

HTTPX 的异步流式 API 使用 async with client.stream(...)aiter_bytes();异步客户端应在应用生命周期结束时通过 aclose() 或异步上下文管理器释放连接。(python-httpx.org)

3. 流式读取中的背压

背压是指下游处理速度限制上游数据读取速度。

例如,服务端以每秒 10 MiB 发送数据,而本地磁盘只能每秒写入 2 MiB:

服务端发送速度:10 MiB/s
磁盘写入速度: 2 MiB/s

如果客户端不断把数据读进内存,内存中的待写数据会增长。流式处理的正确目标不是“边读边写”这么简单,而是让读取速度受到下游容量限制:

读取一块
  → 写入磁盘
  → 写入完成后再读取下一块

异步代码中,如果下游是阻塞的普通文件写入,应注意它会短暂阻塞事件循环。对高吞吐服务,可以使用异步文件库、线程池或专门的对象存储 SDK;关键不是盲目增加 chunk 大小,而是使生产、传输和消费三个阶段有明确的容量边界。

4. 流式请求体

上传大文件时,也不应把文件全部读入内存:

import httpx

with open("large-file.bin", "rb") as file:
    with httpx.Client() as client:
        response = client.post(
            "https://api.example.com/upload",
            content=file,
        )
        response.raise_for_status()

这里请求体是文件对象,客户端可以逐步读取并发送。风险在于:

  • 发送失败后文件指针可能已经移动;
  • 自动重试需要重新打开文件或执行 seek(0)
  • 服务端是否接受分块传输需要确认;
  • 如果服务端要求 Content-Length,应确保客户端能够确定文件大小。

因此,大文件上传的重试设计通常比下载更复杂。


七、TLS:HTTPS 不只是把 URL 改成 https://

1. TLS 解决什么问题

TLS 主要提供三类安全属性:

  1. 认证:客户端验证自己连接的服务器身份;
  2. 机密性:网络观察者不能直接读取应用数据;
  3. 完整性:网络攻击者修改数据时能够被检测。

TLS 1.3 的握手负责协商密码参数、认证通信端点并建立共享密钥;握手完成后,记录协议使用这些密钥保护应用数据。(rfc-editor.org)

HTTPS 的实际路径是:

DNS
  ↓
TCP connect
  ↓
TLS ClientHello / ServerHello
  ↓
证书链验证、主机名验证
  ↓
密钥建立
  ↓
HTTP 请求和响应

因此,HTTPS 连接耗时至少包含:

总耗时 =
连接池等待
+ DNS
+ TCP 建连
+ TLS 握手
+ 发送请求
+ 等待响应
+ 读取响应体

连接池复用的价值之一,就是减少后续请求重复进行 TCP 和 TLS 握手。

2. 默认进行证书和主机名校验

HTTPX 默认验证 HTTPS 连接的身份;证书无效、过期或主机名不匹配时,会抛出 SSL 相关连接错误。(python-httpx.org)

import httpx

try:
    with httpx.Client() as client:
        response = client.get("https://api.example.com")
        response.raise_for_status()
except httpx.ConnectError as exc:
    print(f"TLS 或底层连接失败: {exc}")

不要用下面的方式“修复”生产环境证书错误:

with httpx.Client(verify=False) as client:
    client.get("https://api.example.com")

verify=False 会关闭证书验证,使客户端无法确认对端是不是目标服务器。它可以用于隔离问题的临时实验,但不应作为生产配置。

3. 使用自定义 CA

企业内部服务经常使用私有 CA。正确做法是把该 CA 加入验证上下文:

import ssl
import httpx

context = ssl.create_default_context(
    cafile="/etc/my-company/ca-bundle.pem"
)

with httpx.Client(verify=context) as client:
    response = client.get("https://internal-api.example")
    response.raise_for_status()

这里的文件应当是签发服务端证书的信任根或中间 CA,而不是随意下载的服务端证书。信任范围过大,会扩大客户端接受的证书集合;信任范围过小,则会导致合法服务连接失败。

HTTPX 支持把标准库 ssl.SSLContext 传给客户端,也支持通过 SSL_CERT_FILESSL_CERT_DIR 指定证书来源。(python-httpx.org)

4. 双向 TLS:mTLS

普通 HTTPS 通常只验证服务器:

客户端验证服务器
服务器不要求客户端证书

双向 TLS 还要求客户端提供证书:

import ssl
import httpx

context = ssl.create_default_context(
    cafile="/etc/my-company/ca-bundle.pem"
)
context.load_cert_chain(
    certfile="/etc/my-company/client.crt",
    keyfile="/etc/my-company/client.key",
)

with httpx.Client(verify=context) as client:
    response = client.get("https://mtls.internal.example")
    response.raise_for_status()

mTLS 的关键关系是:

  • 客户端证书证明客户端拥有对应私钥;
  • 服务端需要信任签发客户端证书的 CA;
  • 客户端仍然需要验证服务端证书;
  • 证书文件权限必须限制,私钥不能进入日志、镜像层或普通用户可读目录。

TLS 规范中的 CertificateCertificateVerifyFinished 消息共同参与证书认证、握手签名和握手完整性确认。(rfc-editor.org)

5. SNI、主机名和代理

HTTPS 证书校验不仅看证书是否由可信 CA 签发,还要看证书中的主机名是否匹配请求目标。

因此,下面两种情况并不等价:

访问 https://api.example.com
访问 https://192.0.2.10

即使 IP 地址最终指向同一台机器,证书也可能只包含 api.example.com,不包含 IP 地址。

通过 HTTP 代理访问 HTTPS 时,常见流程是:

客户端 ── TCP ──> 代理
客户端 ── CONNECT api.example.com:443 ──> 代理
代理建立到目标服务器的隧道
客户端在隧道中执行 TLS 握手

HTTPX 文档将这种方式称为 HTTP tunnel;HTTPS 请求通常先连接 HTTP 代理,再通过 CONNECT 建立到目标的隧道。(python-httpx.org)


八、同步和异步:改变等待方式,不改变 HTTP 语义

1. 同步客户端

同步客户端适合:

  • 命令行脚本;
  • 后台任务;
  • 不运行事件循环的普通程序;
  • 调用链本身是同步的服务。
import httpx

def fetch_user(user_id: int) -> dict:
    with httpx.Client(
        base_url="https://api.example.com",
        timeout=httpx.Timeout(5.0),
    ) as client:
        response = client.get(f"/users/{user_id}")
        response.raise_for_status()
        return response.json()

如果该函数频繁调用,客户端应提升到更长生命周期,而不是每次调用都创建:

import httpx


class UserAPI:
    def __init__(self) -> None:
        self.client = httpx.Client(
            base_url="https://api.example.com",
            timeout=httpx.Timeout(5.0),
        )

    def close(self) -> None:
        self.client.close()

    def fetch_user(self, user_id: int) -> dict:
        response = self.client.get(f"/users/{user_id}")
        response.raise_for_status()
        return response.json()

生产代码中应把 close() 接入应用关闭生命周期,否则进程退出前可能留下未释放的资源。

2. 异步客户端

异步客户端适合事件循环中的 I/O 并发:

import asyncio

import httpx


async def fetch_one(
    client: httpx.AsyncClient,
    user_id: int,
) -> dict:
    response = await client.get(f"/users/{user_id}")
    response.raise_for_status()
    return response.json()


async def main() -> None:
    async with httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=httpx.Timeout(5.0),
    ) as client:
        users = await asyncio.gather(
            fetch_one(client, 1),
            fetch_one(client, 2),
            fetch_one(client, 3),
        )
        print(users)


asyncio.run(main())

这里的并发不意味着创建三个客户端,而是三个任务共享同一个客户端和连接池。客户端内部会根据连接池上限分配连接;如果并发任务多于可用连接,超出的任务会等待或触发 PoolTimeout

3. 在 FastAPI 中管理客户端生命周期

对于 ASGI 应用,不应在每个接口调用中创建客户端。可以在应用生命周期中创建和关闭:

from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI, Request


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http = httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=httpx.Timeout(
            connect=3.0,
            read=10.0,
            write=10.0,
            pool=2.0,
        ),
    )
    try:
        yield
    finally:
        await app.state.http.aclose()


app = FastAPI(lifespan=lifespan)


@app.get("/proxy/users/{user_id}")
async def proxy_user(user_id: int, request: Request):
    client: httpx.AsyncClient = request.app.state.http

    try:
        response = await client.get(f"/users/{user_id}")
        response.raise_for_status()
        return response.json()
    except httpx.TimeoutException:
        return {"error": "upstream timeout"}
    except httpx.HTTPStatusError as exc:
        return {
            "error": "upstream status",
            "status": exc.response.status_code,
        }
    except httpx.RequestError:
        return {"error": "upstream network error"}

真实项目中通常应把异常转换为明确的 HTTP 响应,而不是直接返回普通字典;示例为了突出客户端错误分类,省略了具体的错误响应模型。


九、错误处理:区分传输错误、协议错误和业务错误

HTTP 客户端失败至少分为三层。

1. 传输层错误

请求没有得到可用的 HTTP 响应:

DNS 失败
TCP 失败
TLS 失败
连接池超时
读取超时
写入超时

HTTPX 的典型异常包括:

httpx.ConnectError
httpx.ConnectTimeout
httpx.ReadTimeout
httpx.WriteTimeout
httpx.PoolTimeout
httpx.NetworkError

这些异常继承自请求或传输错误层次,可以统一捕获,也可以按阶段分别处理。(python-httpx.org)

2. HTTP 协议层错误

服务器返回了 HTTP 响应,但响应本身不满足协议或客户端预期,例如:

  • HTTP 状态行格式错误;
  • 响应体无法按声明编码解码;
  • 重定向次数过多;
  • 连接中途断开;
  • 响应流已经被关闭。

这类错误不是“没有响应”,而是“响应无法被正确解释”。

3. 业务层错误

服务器返回了合法 HTTP 响应,但业务结果是失败:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

或者:

HTTP/1.1 200 OK

{"code": "INSUFFICIENT_BALANCE"}

200 OK 不等于业务成功。客户端应同时检查:

response.raise_for_status()
payload = response.json()

if payload.get("code") != "OK":
    raise RuntimeError(f"业务失败: {payload}")

如果 API 契约规定所有错误使用 4xx/5xx,则可以通过 raise_for_status() 处理 HTTP 层错误;如果业务错误被放进 2xx 响应,就必须按照响应模型检查。


十、一个可运行的端到端示例

下面的服务模拟:

  • 一个慢接口;
  • 一个临时返回 503 的接口;
  • 一个流式响应接口。

1. 启动测试服务

安装依赖:

python -m pip install fastapi uvicorn httpx

创建 server.py

import asyncio

from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()


@app.get("/slow")
async def slow():
    await asyncio.sleep(1)
    return {"ok": True}


@app.get("/stream")
async def stream():
    async def generate():
        for index in range(5):
            yield f"line-{index}\n".encode()
            await asyncio.sleep(0.2)

    return StreamingResponse(
        generate(),
        media_type="application/octet-stream",
    )

启动:

uvicorn server:app --host 127.0.0.1 --port 8000

2. 客户端代码

创建 client.py

import httpx


def main() -> None:
    timeout = httpx.Timeout(
        connect=1.0,
        read=2.0,
        write=2.0,
        pool=1.0,
    )

    limits = httpx.Limits(
        max_connections=10,
        max_keepalive_connections=5,
        keepalive_expiry=10.0,
    )

    with httpx.Client(
        base_url="http://127.0.0.1:8000",
        timeout=timeout,
        limits=limits,
    ) as client:
        response = client.get("/slow")
        response.raise_for_status()
        print("普通响应:", response.json())

        with client.stream("GET", "/stream") as response:
            response.raise_for_status()

            print("流式响应:")
            for chunk in response.iter_bytes():
                print(repr(chunk))


if __name__ == "__main__":
    main()

运行:

python client.py

预期输出类似:

普通响应: {'ok': True}
流式响应:
b'line-0\n'
b'line-1\n'
b'line-2\n'
b'line-3\n'
b'line-4\n'

每一步的原因是:

  1. Timeout 防止连接、读取、写入和连接池等待无限持续;
  2. Limits 限制并发连接和空闲连接;
  3. Client 让多个请求共享连接池;
  4. raise_for_status() 将 4xx/5xx 转成异常;
  5. client.stream() 不把完整响应体一次性加载到内存;
  6. with 保证客户端和响应流最终关闭。

十一、在 ASGI 测试中绕过真实网络

ASGI 是异步服务器网关接口规范,用于定义 Python Web 应用与服务器之间的调用协议。测试 HTTP 客户端时,可以让 HTTPX 通过 ASGITransport 直接调用应用,而不经过 TCP、DNS 和真实 TLS。

FastAPI 官方文档使用 httpx.AsyncClientASGITransport 编写异步测试。(fastapi.tiangolo.com)

import pytest
from httpx import ASGITransport, AsyncClient

from server import app


@pytest.mark.anyio
async def test_slow():
    transport = ASGITransport(app=app)

    async with AsyncClient(
        transport=transport,
        base_url="http://test",
    ) as client:
        response = await client.get("/slow")

    assert response.status_code == 200
    assert response.json() == {"ok": True}

这个测试验证的是:

HTTPX 请求构造
  → ASGI scope
  → FastAPI 路由
  → ASGI response messages
  → HTTPX Response

它不会验证真实网络中的:

  • DNS;
  • TCP 连接;
  • TLS 证书;
  • 代理;
  • 服务端监听端口;
  • 真实连接池行为。

因此,ASGITransport 适合应用级测试,但不能替代真实 HTTPS 和网络故障测试。

如果应用依赖启动和关闭事件,还需要显式管理 ASGI lifespan;FastAPI 文档指出,直接使用 AsyncClient 时不会自动触发生命周期事件。(fastapi.tiangolo.com)


十二、诊断:从现象反推故障阶段

1. 大量 PoolTimeout

可能路径:

请求到达客户端
  → 连接池没有空闲连接
  → 已达到 max_connections
  → 等待 pool timeout

优先检查:

  • 是否每次调用都创建客户端;
  • 是否有响应体未读取或未关闭;
  • 是否下载、上传任务长期占用连接;
  • max_connections 是否低于实际并发;
  • 上游响应时间是否突然增加。

不要一看到 PoolTimeout 就直接把连接数调大。连接泄漏和响应未关闭同样会造成池耗尽。

2. 大量 ReadTimeout

可能路径:

连接已建立
  → 请求已发送
  → 等待响应体下一块数据超时

检查:

  • 服务端是否真的处理很慢;
  • 响应是否是长轮询或流式接口;
  • read timeout 是否被误当成总 deadline;
  • 上游是否已经返回部分数据;
  • 重试该请求是否会产生重复副作用。

3. CERTIFICATE_VERIFY_FAILED

常见原因:

  • 证书过期;
  • 主机名与证书 SAN 不匹配;
  • 客户端缺少内部 CA;
  • 系统时间错误;
  • 代理进行了 TLS 中间人解密,但客户端不信任代理 CA;
  • 服务端未发送完整证书链。

诊断时可以临时查看证书链,但不要以关闭校验作为修复方案。最终应修复 CA、证书链、主机名或代理信任配置。

4. 重试后服务端压力更大

如果客户端重试间隔固定:

第 1 次失败:所有客户端等待 1 秒
第 2 次失败:所有客户端等待 2 秒
第 3 次失败:所有客户端等待 4 秒

大量客户端会在相同时间再次冲击上游。应使用随机抖动,并设置:

  • 最大尝试次数;
  • 最大总时间;
  • 最大退避时间;
  • 可重试错误集合;
  • 幂等键或状态查询;
  • Retry-After 的尊重。

十三、几个容易混淆的结论

1. 连接池不是缓存

连接池缓存的是底层连接资源,不是 HTTP 响应。它不会让业务数据自动变成缓存,也不等价于 HTTP 缓存。

连接池:减少 TCP/TLS 建连成本
HTTP 缓存:减少重复获取资源的成本

二者解决的是不同问题。

2. 超时不是取消服务端执行

客户端超时后,通常只能停止等待或关闭本地连接:

客户端:我不等了
服务端:可能仍在继续执行

因此,超时后的写操作仍然需要考虑服务端是否已生效。对于支付、下单、发送消息等操作,应使用幂等键、查询接口或事务状态,而不是仅靠客户端重试。

3. 流式读取不是自动限速

流式 API 只改变数据交付方式,不自动保证:

  • 下游处理速度;
  • 磁盘写入速度;
  • 内存上限;
  • 连接及时释放;
  • 取消时服务端停止生成数据。

应用仍需要在上下文管理器中消费或关闭响应,并为大文件、慢消费者和取消路径设计资源释放。

4. TLS 加密不等于业务安全

TLS 可以保护客户端与服务端之间的链路,但不能防止:

  • 客户端把密码写入日志;
  • 服务端返回恶意 JSON;
  • API 权限配置错误;
  • 重放业务请求;
  • 应用层越权;
  • 代理或服务端本身泄露数据。

HTTPS 是传输层安全,不会自动替代认证、授权、幂等、审计和输入校验。


结语:把 HTTP 客户端当成一个有状态的资源管理器

一个可靠的 Python HTTP 客户端至少应明确以下关系:

连接池
  决定连接如何创建、等待、复用和关闭

超时
  决定每个网络阶段最多等待多久

重试
  决定哪些失败可以再次发送,以及是否可能重复执行

流式
  决定响应或请求体如何分块传输、消费和释放

TLS
  决定通信双方如何认证,以及数据如何加密和校验

其中最重要的因果链是:

客户端复用连接
  → 减少 TCP/TLS 握手
  → 降低单次请求成本

客户端限制连接数
  → 并发超过上限时进入池等待
  → 等待过久触发 PoolTimeout

客户端设置读取超时
  → 只能限制单次读取等待
  → 不代表服务端没有执行请求

客户端自动重试
  → 可能再次产生业务副作用
  → 必须结合幂等性和请求可重复性

客户端流式读取
  → 降低单次内存占用
  → 但必须正确关闭响应并处理背压

客户端验证 TLS
  → 确认连接对端身份
  → 不能通过 verify=False 作为生产修复

当这些阶段被分别配置、监控和测试时,HTTP 客户端才不再是一个隐藏网络细节的函数调用,而是一个可以推理、诊断和控制的系统组件。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。