在 HTTPX 中复用连接并控制共享请求配置

来源:HTTPX 官方文档 Clients(页面未单列个人作者)。中文翻译与技术整理:未完纪。2026-10-05 核对当前页面,原页未固定完整发行版本。

HTTPX请求配置流程:Client默认头、查询参数和Cookie与请求级配置合并,build_request生成请求后可删除特定头,再由Client连接池发送并在退出时关闭。
图:HTTPX 配置进入请求对象的流程,未完纪原创示意;不表示实际网络抓包。

如果一个程序已经超出一次性试验、临时脚本或原型的范围,HTTPX 官方文档建议使用 Client。熟悉 Requests 的读者,可以把 httpx.Client() 理解为对应 requests.Session() 的常用入口。顶层 httpx.get() 等 API 每次请求都需要建立连接;Client 则维护连接池,多次访问同一主机时可以复用底层 TCP 连接。

复用连接可减少重复握手、CPU 开销、往返次数和网络拥塞,但实际收益由网络、服务端和负载决定,本轮没有做性能测量。Client 还提供跨请求 Cookie 持久化、共享配置、代理配置和 HTTP/2 等能力。

原文:HTTPX — Clients。以下逐节整理全部正文;主要讨论客户端配置边界,进度和上传示例属于同一源页的后续内容。

为连接池规定清晰的生命周期

推荐将 Client 用作上下文管理器,退出 with 块时自动清理连接。无法用 with 时,就在 finally 中调用 close()。不要在循环的每一步重新创建 Client,否则会失去跨请求复用的意义。

import httpx

with httpx.Client() as client:
    response = client.get("https://example.com")
    custom = client.get("https://example.com", headers={"X-Custom": "value"})
    print(custom.request.headers["X-Custom"])

# 另一种生命周期写法:
client = httpx.Client()
try:
    response = client.get("https://example.com")
finally:
    client.close()

client.get()、client.post() 等方法接受与相应顶层方法相同的常用参数。上例的 value 是请求对象里设置的字段,不是服务端一定回传了这个值;原文展示的 200 响应也不保证示例站点在任何时刻都返回同样结果。

共享配置:先区分合并与覆盖

构造 Client 时传入 headers,可为后续请求提供默认请求头。例如设置 user-agent 为 my-app/0.0.1,然后访问回显接口查看收到的头。原文这处示范使用 http://httpbin.org/headers;这里改为 HTTPS,其他意图不变。示例服务是公共站点,不能拿它接收真实密钥或私有数据。

with httpx.Client(headers={"user-agent": "my-app/0.0.1"}) as client:
    response = client.get("https://httpbin.org/headers")
    print(response.json()["headers"]["User-Agent"])

headers、查询参数 params 和 cookies 同时出现在客户端与单次请求中时,会进行合并。原文用不同键说明两边的值都会保留。其他参数采用请求级值优先。下面的 URL 和头部结果是原文示意,不是本轮执行日志。

with httpx.Client(
    headers={"X-Auth": "from-client"},
    params={"client_id": "client1"},
) as client:
    response = client.get(
        "https://example.com",
        headers={"X-Custom": "from-request"},
        params={"request_id": "request1"},
    )
    print(response.request.url)
    print(response.request.headers["X-Auth"])
    print(response.request.headers["X-Custom"])

# 原文示意:
# https://example.com?client_id=client1&request_id=request1
# from-client
# from-request

原文认证覆盖示例在 Client 中设置 tom/mot123,单次请求又设置 alice/ecila123;最终 Basic Authorization 对应 alice:ecila123。它展示的是覆盖行为。原文用 Base64 解码头部来确认这一点;Base64 是编码,不能当成密码保护。上述字符串是文档演示凭证,实际代码不要硬编码密码或把认证头打印到日志。下面改用固定虚构凭证和本地 MockTransport 展示同一覆盖关系,不发起外部网络连接;这是编辑补充,未执行。

# 编辑补充:MockTransport 在内存中处理请求,不连接外部网络。
# 只使用下列虚构演示凭证;本轮未执行。
import base64
import httpx

def local_response(request):
    return httpx.Response(200, request=request)

with httpx.Client(
    auth=("tom", "demo-client"),
    transport=httpx.MockTransport(local_response),
) as client:
    response = client.get(
        "https://example.com",
        auth=("alice", "demo-request"),
    )
    _, _, encoded = response.request.headers["Authorization"].partition(" ")
    assert base64.b64decode(encoded) == b"alice:demo-request"
# 不打印认证头或解码后的值。assert是待验证示例,不是本轮测试结论。

base_url 等选项只能在 Client 层配置。Client(base_url='https://httpbin.org') 配合 client.get('/headers') 会构成 https://httpbin.org/headers。这里同样将源文的 HTTP 换成 HTTPS。不要把可控的相对路径当作完整的目标地址安全校验;如果请求地址来自外部输入,应用仍要明确允许访问的主机与路径。

先构建 Request,再精确修改

httpx.Request('GET', 'https://example.com') 可以直接构造请求,再通过 client.send(request) 发送。若需要先合入 Client 的默认配置,然后删除或修改某个字段,使用 client.build_request() 更合适;这一步与立即发送请求是不同的操作。

# 原文以 ... 表示演示用 API key;不要替换成写入源码的真实秘密。
headers = {"X-Api-Key": "...", "X-Client-ID": "ABC123"}

with httpx.Client(headers=headers) as client:
    request = client.build_request("GET", "https://api.example.com")
    print(request.headers["X-Client-ID"])
    del request.headers["X-Api-Key"]
    response = client.send(request)

删除动作发生在发送之前,因此这次请求不再携带该 API key。Client 共享的敏感头应仅用于预期服务,特别要检查一个客户端是否被拿去访问不同信任域。示例中的 api.example.com 是演示域名,不保证有可用 API。

下载进度:按已接收的原始字节计数

大响应可以流式读取,并观察 response.num_bytes_downloaded。启用 HTTP 压缩时,解压后迭代得到的内容长度可能与 Content-Length 表示的传输长度不同,因此直接累计解码后的 chunk 长度未必得到正确进度。原文分别用 tqdm 与 rich 展示进度条,两者都把 num_bytes_downloaded 作为计数依据。

# 原文 tqdm 示例;外部 URL 和响应格式需由使用者重新核验。
import tempfile
import httpx
from tqdm import tqdm

with tempfile.NamedTemporaryFile() as download_file:
    url = "https://speed.hetzner.de/100MB.bin"
    with httpx.stream("GET", url) as response:
        total = int(response.headers["Content-Length"])
        with tqdm(total=total, unit_scale=True, unit_divisor=1024, unit="B") as progress:
            downloaded = response.num_bytes_downloaded
            for chunk in response.iter_bytes():
                download_file.write(chunk)
                progress.update(response.num_bytes_downloaded - downloaded)
                downloaded = response.num_bytes_downloaded
# 原文 rich 的等价展示路径。
import tempfile
import httpx
import rich.progress

with tempfile.NamedTemporaryFile() as download_file:
    with httpx.stream("GET", "https://speed.hetzner.de/100MB.bin") as response:
        total = int(response.headers["Content-Length"])
        with rich.progress.Progress(
            "[progress.percentage]{task.percentage:>3.0f}%",
            rich.progress.BarColumn(bar_width=None),
            rich.progress.DownloadColumn(),
            rich.progress.TransferSpeedColumn(),
        ) as progress:
            task = progress.add_task("Download", total=total)
            for chunk in response.iter_bytes():
                download_file.write(chunk)
                progress.update(task, completed=response.num_bytes_downloaded)

这两段原文示例直接读取 Content-Length,没有处理该头缺失、无效或响应为错误页面的情况;还没有调用 raise_for_status()。实际接入时应先检查状态码,把缺失总长度当作“不确定总量”,同时设置下载体积上限和合适超时。这里保留原始机制并明确指出局限,没有下载示例的 100MB 文件,也没有验证该旧地址目前的内容。

tqdm 下载进度原图
tqdm 下载进度原图。HTTPX 官方文档提供,非本文下载或测速;上传段落在原文复用同一 tqdm 动画。
rich 下载进度原图
rich 下载进度原图。HTTPX 官方文档提供,非本文下载或测速;上传段落在原文复用同一 tqdm 动画。

上传进度:由请求内容生成器报告

流式上传可以把生成器作为 content。原文用 32 MiB 随机数据构造 BytesIO,每次读取 1,024 字节并 yield,然后更新进度。进度表达的是数据交给发送链路的数量,并不是服务端已持久化的确认。

import io
import random
import httpx
from tqdm import tqdm

def gen():
    total = 32 * 1024 * 1024
    with tqdm(ascii=True, unit_scale=True, unit="B", unit_divisor=1024, total=total) as bar:
        with io.BytesIO(random.randbytes(total)) as source:
            while data := source.read(1024):
                yield data
                bar.update(len(data))

httpx.post("https://httpbin.org/post", content=gen())

这段数据虽然按块发送,但 random.randbytes(total) 已预先在内存生成整个缓冲区,不能据此宣称生成端为恒定内存。random.randbytes 也不是密码学随机源。需要上传真实文件时,应先确认目标与数据授权,避免把个人文件送往公共回显服务。

multipart 文件与多个同名字段

files 的字典键是字段名;值可以是文件对象、字符串,或元组。源教程展示 2 到 3 个元素的形式;当前官方 FileField 实现也支持四元组,第四项为该部分的 headers,不应把教程展示范围写成 API 的全部能力。元组第一项是可选文件名(可为 None),第二项是文件对象或会被 UTF-8 编码的字符串,第三项是可选 MIME 类型。未指定 MIME 时,HTTPX 会按文件名推测;未知扩展名通常采用 application/octet-stream。这里需要纠正原文的一处泛化:按当前官方 FileField 实现,只有未显式提供 MIME 类型且文件名为 None 时,才不会从文件名推测并生成 Content-Type;如果三元组第三项显式设置 text/plain,该部分仍会携带 Content-Type。下方无文件名示例正是这种情况。

with open("report.xls", "rb") as report_file:
    files = {"upload-file": ("report.xls", report_file, "application/vnd.ms-excel")}
    response = httpx.post("https://httpbin.org/post", files=files)

# 没有文件名的文本部分:
files = {"upload-file": (None, "text content", "text/plain")}
response = httpx.post("https://httpbin.org/post", files=files)

# 两份文件共用 images 字段:用列表保留重复字段名。
with open("foo.png", "rb") as foo_file, open("bar.png", "rb") as bar_file:
    files = [
        ("images", ("foo.png", foo_file, "image/png")),
        ("images", ("bar.png", bar_file, "image/png")),
    ]
    response = httpx.post("https://httpbin.org/post", files=files)

普通表单字段可以另用 data 传入。文件上传默认按流处理,一次只加载一个块;这不等于可以忽略文件大小限制、错误状态和服务端接收能力。源页无文件名文本示例的输入为 'text content',展示响应却写成 'text-content',两者并不一致,不能从那个响应字符串推断 HTTPX 会自动替换空格。

本地模拟请求的接口见 HTTPX Mock transports 官方说明。上述 MIME 勘误依据 HTTPX 官方 _multipart.py 中 FileField 的内容类型处理静态核对;它不是本轮抓包结果。

原文两段 multipart 示例展示的响应片段如下;第二段保留原文 text-content 与输入 text content 不一致的情况,不能当成实际转换规则。

{
  ...
  "files": {"upload-file": "<... binary content ...>"},
  ...
}
{
  ...
  "files": {},
  "form": {"upload-file": "text-content"},
  ...
}

审核结论与来源

本轮只完成静态核对。已指出源文 HTTP 回显地址、演示密码、缺少错误处理与内容长度假设,以及上传示例的预分配内存问题。没有执行任何网络请求示例、下载大文件、发送真实凭证或上传本地材料;未发现其他明显危险操作不意味着完成了软件漏洞评估。

来源与署名:HTTPX 官方文档 Clients;原页未单列个人作者。项目版权 © 2019 Encode OSS Ltd,代码 BSD-3-Clause,完整许可附于本文末尾。中文翻译及编辑标注日期:2026-10-05;本文标明的安全建议和修订由未完纪补充。

版权与许可全文

以下保留本页涉及的来源材料或示例代码的版权、许可条件与免责声明;各自适用范围依原声明。中文翻译及编辑标注:未完纪,2026-10-05。

LICENSE-source.txt

BSD 3-Clause License

Copyright © 2019, Encode OSS Ltd (https://www.encode.io/).
All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容