摘要
[WinError 10053] 中止已建立的连接、RemoteDisconnected 常被混为一谈。本文用一个空闲 120 毫秒就关连接的本地服务端,把三条成因分开量:跨不过空闲超时,http.client 每 2 次调用炸 1 次,requests/httpx 不抛异常但 p95 从 2.4 涨到 24--28 毫秒;不复用则每请求留 1 个 TIME_WAIT,实测存活 112--122 秒、动态端口 13977 个,稳态 115--125 QPS 就撞端口;池容量改变的是握手数。
一、先分清三类故障:它们的修法互相冲突
线上最常见的三类表现成因完全不同,很多帖子把它们搅在一起:
- A 类:连接被对端关掉,客户端还在用池里那条 。表现为
ConnectionAbortedError/RemoteDisconnected/ httpx 的ReadError。根因是空闲超时,与并发量无关,低频调用比重压测更容易撞上。 - B 类:连接建得太快太猛,动态端口不够用 。表现为
WinError 10048、Linux 上Cannot assign requested address。根因是每请求新建连接留下的 TIME_WAIT。 - C 类:池太小把请求堵在本地。表现为排队变慢而不是报错,握手数会暴露它。
#mermaid-svg-IaOb6dfgBHdNqG27{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IaOb6dfgBHdNqG27 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IaOb6dfgBHdNqG27 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IaOb6dfgBHdNqG27 .error-icon{fill:#552222;}#mermaid-svg-IaOb6dfgBHdNqG27 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IaOb6dfgBHdNqG27 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IaOb6dfgBHdNqG27 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IaOb6dfgBHdNqG27 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IaOb6dfgBHdNqG27 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IaOb6dfgBHdNqG27 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IaOb6dfgBHdNqG27 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IaOb6dfgBHdNqG27 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IaOb6dfgBHdNqG27 .marker.cross{stroke:#333333;}#mermaid-svg-IaOb6dfgBHdNqG27 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IaOb6dfgBHdNqG27 p{margin:0;}#mermaid-svg-IaOb6dfgBHdNqG27 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IaOb6dfgBHdNqG27 .cluster-label text{fill:#333;}#mermaid-svg-IaOb6dfgBHdNqG27 .cluster-label span{color:#333;}#mermaid-svg-IaOb6dfgBHdNqG27 .cluster-label span p{background-color:transparent;}#mermaid-svg-IaOb6dfgBHdNqG27 .label text,#mermaid-svg-IaOb6dfgBHdNqG27 span{fill:#333;color:#333;}#mermaid-svg-IaOb6dfgBHdNqG27 .node rect,#mermaid-svg-IaOb6dfgBHdNqG27 .node circle,#mermaid-svg-IaOb6dfgBHdNqG27 .node ellipse,#mermaid-svg-IaOb6dfgBHdNqG27 .node polygon,#mermaid-svg-IaOb6dfgBHdNqG27 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IaOb6dfgBHdNqG27 .rough-node .label text,#mermaid-svg-IaOb6dfgBHdNqG27 .node .label text,#mermaid-svg-IaOb6dfgBHdNqG27 .image-shape .label,#mermaid-svg-IaOb6dfgBHdNqG27 .icon-shape .label{text-anchor:middle;}#mermaid-svg-IaOb6dfgBHdNqG27 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IaOb6dfgBHdNqG27 .rough-node .label,#mermaid-svg-IaOb6dfgBHdNqG27 .node .label,#mermaid-svg-IaOb6dfgBHdNqG27 .image-shape .label,#mermaid-svg-IaOb6dfgBHdNqG27 .icon-shape .label{text-align:center;}#mermaid-svg-IaOb6dfgBHdNqG27 .node.clickable{cursor:pointer;}#mermaid-svg-IaOb6dfgBHdNqG27 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IaOb6dfgBHdNqG27 .arrowheadPath{fill:#333333;}#mermaid-svg-IaOb6dfgBHdNqG27 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IaOb6dfgBHdNqG27 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IaOb6dfgBHdNqG27 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IaOb6dfgBHdNqG27 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IaOb6dfgBHdNqG27 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IaOb6dfgBHdNqG27 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IaOb6dfgBHdNqG27 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IaOb6dfgBHdNqG27 .cluster text{fill:#333;}#mermaid-svg-IaOb6dfgBHdNqG27 .cluster span{color:#333;}#mermaid-svg-IaOb6dfgBHdNqG27 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IaOb6dfgBHdNqG27 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IaOb6dfgBHdNqG27 rect.text{fill:none;stroke-width:0;}#mermaid-svg-IaOb6dfgBHdNqG27 .icon-shape,#mermaid-svg-IaOb6dfgBHdNqG27 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IaOb6dfgBHdNqG27 .icon-shape p,#mermaid-svg-IaOb6dfgBHdNqG27 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IaOb6dfgBHdNqG27 .icon-shape .label rect,#mermaid-svg-IaOb6dfgBHdNqG27 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IaOb6dfgBHdNqG27 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IaOb6dfgBHdNqG27 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IaOb6dfgBHdNqG27 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 没有,连接被断
是
否却仍失败
能建连但很慢
是
否但仍慢
调用失败或变慢
服务端返回过响应吗?
请求间隔 > 服务端空闲超时?
A 类:陈旧连接
只重试一次 + 重连
查 LB / 中间层 idle timeout
本端 TIME_WAIT 随请求数涨?
B 类:没复用
C 类:池容量/排队
一句话判据:A 类看间隔,B 类看 TIME_WAIT 斜率,C 类看握手数。三个数要分开量,混在一起必然得出互相矛盾的结论。
二、实验台:一个"120 毫秒没活就关连接"的本地服务端
关键在于把空闲超时做成确定性 的,才能扫出拐点。核心两行:settimeout + 超时就走人。
python
import socket, threading
def make_server(idle_s):
"""最小 HTTP/1.1 keep-alive 服务端:空闲超过 idle_s 秒由服务端先关。"""
acc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
acc.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
acc.bind(("127.0.0.1", 0))
acc.listen(128)
n_accept = [0]
def conn(c):
c.settimeout(idle_s) # 就靠这一行造出空闲超时
try:
while True:
try:
data = c.recv(4096)
except socket.timeout:
return # 服务端先关
if not data:
return
if b"\r\n\r\n" in data:
body = b'{"ok":1}'
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
+ ("Content-Length: %d\r\n" % len(body)).encode()
+ b"\r\n" + body) # Content-Length 必须算对,否则下一个响应会串流
except OSError:
pass
finally:
c.close()
def loop():
while True:
try:
c, _ = acc.accept()
except OSError:
return
n_accept[0] += 1
threading.Thread(target=conn, args=(c,), daemon=True).start()
threading.Thread(target=loop, daemon=True).start()
return acc, n_accept
n_accept 是握手计数器:有没有复用,看"40 个请求服务端 accept 了几次"就够了,不用抓包。
TIME_WAIT 按对端端口过滤来数(这样数到的正是消耗本机动态端口的那一侧):
python
import subprocess
def tw_client(server_port):
"""本端为客户端、对端是实验服务端的 TIME_WAIT 条数。"""
n = 0
for l in subprocess.run(["netstat", "-an"], capture_output=True,
text=True).stdout.splitlines():
p = l.split()
if len(p) >= 4 and p[0] == "TCP" and p[-1] == "TIME_WAIT" \
and p[2].endswith(":" + str(server_port)):
n += 1
return n
复现命令:PYTHONIOENCODING=utf-8 python demos/http_keepalive_pool.py rounds=3。每组数字都是连续 3 轮的区间,环境见文末。
三、实测 1:跨不过空闲超时的那一刻,三种客户端三种命运
服务端空闲超时设成 120 毫秒,客户端每次发 2 个请求之间睡 gap 毫秒,每组共 40 次调用。
| 客户端 | gap(毫秒) | 握手数 | 完成请求数 | 抛异常 | p95(毫秒) |
|---|---|---|---|---|---|
| http.client | 20 / 60 / 100 | 1 / 0 / 0 | 40 | 0 | 0.67--0.97 |
| requests | 20 / 60 / 100 | 1 / 0 / 0 | 40 | 0 | 2.41--3.11 |
| httpx | 20 / 60 / 100 | 1 / 0 / 0 | 40 | 0 | 1.29--1.97 |
| http.client | 160 / 260 | 19--20 | 20 | 20/40 | 23.69--25.45 |
| requests | 160 / 260 | 39--40 | 40 | 0 | 25.58--28.17 |
| httpx | 160 / 260 | 39--40 | 40 | 0 | 24.32--27.45 |
拐点压在 120 毫秒上,三件事同时发生:
gap ≤ 100ms时一条连接服务完 40 个请求(accepts 增量为 0,只有首轮建连那 1 次)。gap ≥ 160ms时http.client的异常率精确等于 50%(3 轮都是 20/40),报的是同一句:
text
ConnectionAbortedError: [WinError 10053] 你的主机中的软件中止了一个已建立的连接。
为什么正好一半?失败之后紧接着那次调用会自己重连并成功,然后再空闲超时、再失败------一次炸、一次通,严格交替,160ms 与 260ms 两条曲线 3 轮都是 20/40。监控里见到这么整齐的失败率,基本就是这个成因。
requests和httpx一个异常都不抛,但 p95 从 2.4/1.3 毫秒涨到 24--28 毫秒,握手数从 1 涨到 40 。报错消失了,代价全进了延迟和连接数里。只看异常监控,这类退化是隐形的。
3 轮里只复现出 [WinError 10053];常被一起报告的 [WinError 10054] 我没跑到,不写进结论。
四、实测 2:同一个陈旧连接问题,四种应对到底差在哪
这一步只回答一个问题:加不加异常、加多少握手 。每格 40 次调用,p50/p95 覆盖全部 40 次(含失败那次的耗时)。
| 应对方案 | 抛给调用方的异常 | 首跳失败 | 重试后仍失败 | 握手数 | p50(毫秒) | p95(毫秒) |
|---|---|---|---|---|---|---|
| http.client 裸连不重试 | 20/40(10053) | --- | --- | 20 | 1.05--12.91 | 20.88--25.48 |
| http.client 失败后重连一次 | 0 | 39/40 | 0 | 40 | 14.49--19.42 | 23.09--27.06 |
| requests 默认(urllib3 自愈) | 0 | 静默 | --- | 40 | 16.17--20.04 | 24.29--27.01 |
requests 每请求带 Connection: close |
0 | --- | --- | 40 | 16.94--20.73 | 24.76--27.43 |
结论有三条,第一条来自一次度量纠错。
1)四种方案的 p95 全落在 20.9--27.4 毫秒这一个区间里,耗时上分不出高下。 差别只有两处:异常抛给谁,握手多付多少。
2)"裸连接 p50 只有 1 毫秒、看起来最快"是统计假象。 它的样本是两个峰:失败的那半在几百微秒内抛异常(请求根本没发出去),成功的另一半是一次完整重连(20 毫秒上下)。p50 落在哪个峰取决于抖动,3 轮里它在 1.05--12.91 毫秒之间来回跳。有意义的读法是"它有一半调用压根没完成",而不是"它更快"。
要交代过程:第一版脚本把失败调用排除在延迟样本之外,当时得到的是"重连一次 p95 只有 1.25--19.47 毫秒、明显优于裸连接的 20.5--25.5 毫秒",与修正后的表完全不同。度量口径本身能造出结论------凡是分母会随失败率变化的指标(p95、均值、成功率),必须先写清样本是谁。
3)Connection: close 是最差选项,却常被当成"最保险"的写法。 它让每个请求重新建连(握手 40),p95 24.76--27.43 毫秒,和默认自愈几乎一样,等于把复用的全部收益白送出去换"不会报错"------而 requests 默认本来就不报错。
该做的是只重试一次 + 重连 (表中第二行,重试后仍失败 0/39),或让池的空闲回收时间严格小于服务端空闲超时。
python
import socket
from http.client import HTTPConnection, RemoteDisconnected
def call_once(host, port, path="/x"):
"""陈旧连接的正确处理:只重连一次,第二次还失败就抛给上层。
判据要是"连接语义类异常",不要 except Exception 全兜。"""
conn = HTTPConnection(host, port, timeout=5)
try:
conn.request("GET", path)
return conn.getresponse().read()
except (RemoteDisconnected, ConnectionAbortedError, socket.timeout):
conn.close()
conn = HTTPConnection(host, port, timeout=5) # 换新连接,只试这一次
conn.request("GET", path)
return conn.getresponse().read()
五、实测 3:不复用的代价,一张表看完
同一台服务端,120 个请求,六种写法各跑一遍。tw+ 是该写法在本机新增的 TIME_WAIT 条数。
| 写法 | p50(毫秒) | p95(毫秒) | 120 请求墙钟(毫秒) | tw+ |
|---|---|---|---|---|
raw_keepalive(裸 socket 复用) |
0.07--0.10 | 0.16--0.19 | 9--14 | 0 |
httpx_client(复用 Client) |
0.49--0.80 | 0.82--1.24 | 87--110 | 0 |
session_reuse(复用 Session) |
1.16--1.56 | 1.43--2.30 | 154--205 | 0 |
raw_new_conn(每次新建 socket) |
0.53--0.91 | 0.70--1.20 | 66--112 | 85--91 |
requests.get(每次新建 Session) |
14.99--15.66 | 23.25--27.81 | 1762--1831 | 120 |
httpx.get(每次新建 Client) |
214.67--225.77 | 250.02--278.53 | 25317--27238 | 120 |
三个可直接落地的判断:
requests.get(url)一行写法比 Session 复用慢一个量级 :墙钟 1.76--1.83 秒对 154--205 毫秒,即 8.6--11.9 倍,而且每个请求留一个 TIME_WAIT。httpx.get(url)更贵,因为它每次都重新构造客户端上下文 :p50 214.67--225.77 毫秒,120 个请求墙钟 25.3--27.2 秒,是httpx.Client复用的 230--313 倍。这是"明明没并发却慢得离谱"的标准成因。- 复用的三种写法 tw 增量都是 0------TIME_WAIT 由"谁先关连接"产生,不复用才会产生。
六、实测 4:TIME_WAIT 活多久,端口预算怎么算
100 次 requests.get 之后,按 10 秒一次采样本机 TIME_WAIT 条数:
| 项 | 实测值(3 轮) |
|---|---|
| 100 次请求耗时 | 1.40--1.70 秒(59--71 请求/秒) |
| 本端 TIME_WAIT 峰值 | 99(≈每个请求 1 条) |
| 11--112 秒采样 | 一直是 99 |
| 121--122 秒采样 | 0 |
| 存活时长 | 落在 (112s, 122s] |
| 本机动态端口范围 | start=1024,count=13977 |
把它除一下就是你的可持续新建连接上限:13977 ÷ 112 ≈ 125 次/秒,13977 ÷ 122 ≈ 115 次/秒 。也就是说,在 Windows 上用"每请求新建连接"的写法,稳定态大约 115--125 QPS 就会开始撞 WinError 10048,而不是等到几千 QPS。
必须交代清楚:这个速率是算术外推,我没有真的把端口打满。共享开发机上打满动态端口会连带影响其它进程,所以规模刻意压到 100 请求,用"实测斜率 1.0 条/请求 × 实测存活 112--122 秒"去推,不靠制造故障来验证。
还有一个容易搞错的点:TIME_WAIT 由主动关闭的一方承担 (TCP 规则,本文只实测客户端这侧)。第三节那个"服务端空闲超时先关"的场景,条目就落在服务端,本机 netstat 数不到。排查端口耗尽前先确认:这台机器是不是主动关的那一方,别对着对端的 TIME_WAIT 调自己的参数。
七、实测 5:连接池容量与 pool_block,改变的是握手数
8 线程 × 20 请求 = 160 个请求并发打同一台服务端,只换池配置:
| 池配置 | 握手数 | 墙钟(毫秒) | p95(毫秒) | 异常 |
|---|---|---|---|---|
maxsize=1, block=False |
69--77 | 203--348 | 16.24--28.67 | 0 |
maxsize=1, block=True |
1 | 179--236 | 16.62--24.41 | 0 |
maxsize=4, block=True |
4 | 164--255 | 13.50--19.96 | 0 |
maxsize=10, block=True |
8 | 172--250 | 13.14--18.58 | 0 |
python
import requests
from requests.adapters import HTTPAdapter
sess = requests.Session()
# pool_block=False 时,池满了 urllib3 不会排队,而是临时新建一条连接用完就丢:
# 实测握手数 69~77(160 个请求),这些被丢弃的连接关完后全变 TIME_WAIT。
sess.mount("http://", HTTPAdapter(pool_connections=10, pool_maxsize=10, pool_block=True))
读法:
block=False不是"不排队就快速通过",是"不排队就疯狂建连" 。握手数 69--77 对比block=True的 1,这是池容量不足的真实代价,还会顺带把 B 类故障(端口耗尽)喂出来。pool_block=True+ 小池不报错,只排队 。本文是 loopback、单次请求不到 1 毫秒,4 种配置的墙钟差异不明显(164--348 毫秒区间重叠);真实网络里排队时间按上游 RTT 放大,这张表的墙钟列不能外推,握手数列可以------它只取决于配置。- 想让超额请求快速失败,得在业务层做(超时 + 熔断),别指望 urllib3 抛异常。
八、避坑指南
- 别把空闲超时的断连当"网络不稳"去加次数 。先用"请求间隔 > 服务端空闲超时"复现一次,能复现就是陈旧连接,修法是只重试一次 + 重连(实测重试后仍失败 0/39),不是加到三次。
- 低频任务比重压测更容易踩 。压测间隔远小于空闲超时,连接一直活着;上线后每 5 分钟一次的定时任务第一条就炸。测试要专门造"慢间隔"用例,这是拐点能被扫出来的唯一原因。
- 不报错不代表没问题 。requests/httpx 会静默重建陈旧连接:异常数从 20 变 0,同时 p95 从 2.4--3.1 毫秒涨到 24--28 毫秒、握手数从 1 涨到 40。只看异常监控会漏掉这类退化。
- 失败率整齐就是线索 。3 轮里裸
http.client都是 20/40 一次不差------失败与自动恢复严格交替。随机网络故障给不出这么干净的数。 requests.get()/httpx.get()在循环里是两种病 。前者每次新建 Session(p50 14.99--15.66 毫秒,tw+120),后者每次新建 Client(p50 214.67--225.77 毫秒,120 请求墙钟 25.3--27.2 秒)。- 拿
Connection: close当保险是最差选择。p95 与默认自愈没差别、握手数一样 40,只是白送复用收益。 - 两端超时对齐方向必须是客户端更短,否则每次池回收都发生在对端关闭之后,池里全是陈旧连接。
- 不要在共享机器上真的把动态端口打满 。本文端口数字是小规模实测(100 请求、斜率 1.0 条/请求)加算术推算(13977 ÷ 112~122 ≈ 115--125 次/秒),故意没触发
10048;要验证上限请在可控环境做。
九、结论:按角色给旋钮
| 你是谁 | 先量哪个数 | 动哪个旋钮 |
|---|---|---|
| 写脚本 / 定时任务 | 是否每次调用都在新建连接(墙钟差 8.6--313 倍) | 复用 Session/Client;跨空闲超时的场景只重试一次 |
| 服务调用方 | 池满时的握手数 | pool_maxsize ≥ 并发数,pool_block=True;快速失败在业务层做 |
| 网关 / LB 维护方 | 客户端间隔与你的 idle timeout 谁大 | 两端超时对齐(客户端更短),并告知调用方"代价在 p95" |
| 排障 / 容量 | 本端 TIME_WAIT 斜率与存活秒数 | 动态端口数 ÷ 存活秒 = 可持续新建连接 QPS(本机 115--125) |
三个能直接抄走的数:陈旧连接的代价是 p95 从 2.4 毫秒涨到 24--28 毫秒,不是报错 ;每请求新建连接 ≈ 每请求 1 条 TIME_WAIT,存活 112--122 秒 ;池配置错误先把握手数放大到 69--77,再顺带制造端口耗尽。
附录 A:自检清单
| 检查项 | 怎么测 | 期望 |
|---|---|---|
| 服务端空闲超时是多少 | 扫请求间隔,看握手数何时与请求数 1:1 | 找到拐点(本文 120ms) |
| 有没有复用 | 服务端计数 accepts / reqs |
远大于 1(本文 40 请求 1 握手) |
| 有没有静默重连 | 异常数为 0 但 p95 跳一个量级 | 判定陈旧连接,不是"网络抖动" |
| 本端 TIME_WAIT 斜率 | netstat -an 按对端端口过滤 |
复用时应为 0 |
| 池是不是排队点 | 对比 pool_block 开关下的握手数 |
明确代价归属 |
附录 B:三个术语
- TIME_WAIT :主动关闭连接的一方要保持该四元组一段时间,防止迟到报文串进新建连接;每条占用一个本机临时端口。
pool_block:池满时排队等待(True)还是临时新建一条用完就丢(False,urllib3 默认)。- 动态端口范围 :本机
netsh int ipv4 show dynamicport tcp读到 start=1024、count=13977。
附录 C:数据来源
- 实验台
demos/http_keepalive_pool.py,transcriptdemos/http_keepalive_pool.transcript.txt。 - 环境:Windows 10.0.26200 / Python 3.11.5 / requests 2.31.0 / httpx 0.28.1 / urllib3 2.7.0。
- exp1--exp4 连跑 3 轮;exp5 因度量口径修正单独重跑 3 轮,旧口径版本已作废,transcript 里有说明。