阻塞客户端的超时按段计,慢 body 永远读不完也不超时
主线位置:阻塞客户端的请求主线和响应主线。
blocking::Client把请求交给后台线程上的异步客户端,调用线程在wait::timeout里停车等结果;它的超时就是每次停车的上限。后台线程、通道和wait::timeout的实现见 阻塞客户端。
flowchart TB send["send 等响应头"] -->|"wait 计时 1"| head["拿到头"] head --> read1["read 第 1 块"] read1 -->|"wait 计时 2"| read2["read 第 2 块"] read2 -->|"wait 计时 3"| more["每块重新计时"] more --> done["总时长没有上限"] rbt["RequestBuilder timeout"] --> total["异步侧总超时"]
实验 crate:exp-reqwest/pitb-lab(src/bin/blktmo.rs)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,客户端 .no_proxy(),连跑 3 次,毫秒级一致。服务器是 testsrv,跑在另一个线程自己的运行时上:trickle 立刻回头,然后 5 块、每块 1 字节、间隔 0.7 秒(2.8 秒读完);slow 0.7 秒后回头,再 2 块、间隔 0.7 秒。
现象
一个批处理脚本用阻塞客户端下载文件,timeout(1s) 的本意是「一秒拿不到就算了」:
let c = Client::builder().no_proxy().timeout(one_s).build().unwrap();
let t0 = Instant::now();
let r = c.get(&trickle).send().and_then(|mut r| r.copy_to(&mut Vec::new()));
show("Client timeout(1s), copy_to trickle", t0, r);
let t0 = Instant::now();
let r = c.get(&slow).send().and_then(|r| r.text()).map(|t| t.len());
show("Client timeout(1s), send+text slow", t0, r);
Client timeout(1s), copy_to trickle 2.81s Ok(5)
Client timeout(1s), send+text slow 1.41s Ok(2)
- 设的是 1 秒,
copy_to用了 2.81 秒,成功。服务器每 0.7 秒给一个字节,每一次读都没超过 1 秒。只要间隔小于设的值,body 再长也不会超时。 send()等头 0.7 秒,text()读体 0.7 秒,各自都在 1 秒以内,合计 1.41 秒,成功。- 什么都不设时是默认的 30 秒,同样按段计。
原因
阻塞客户端的超时存在 ClientHandle 里,不交给异步客户端。默认值是 Timeout 的 30 秒:
impl Default for Timeout {
fn default() -> Timeout {
// default mentioned in ClientBuilder::timeout() doc comment
Timeout(Some(Duration::from_secs(30)))
}
}
它只是每次 wait::timeout 的上限。一个请求里 wait::timeout 被调用多次,每次从零计:send() 等响应头一次;text / bytes / json 读完整个体一次;Read::read 每调一次一次:
impl Read for Response {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
use futures_util::io::AsyncReadExt;
let timeout = self.timeout;
wait::timeout(self.body_mut().read(buf), timeout).map_err(|e| match e {
wait::Waited::TimedOut(e) => crate::error::decode(e).into_io(),
wait::Waited::Inner(e) => e,
})
}
}
copy_to、std::io::copy、BufReader 都是一次次调 read,所以每一块都拿到一个新的 1 秒。它管的是「每次等多久」,不是「整个请求多久」。异步客户端那边什么超时都没设,没有人管总时长。
blocking::ClientBuilder 也没有 read_timeout 可以换(阻塞客户端 列了缺的三个方法)。
修法
一:给请求设总超时。 RequestBuilder::timeout 不一样:into_async 把它放进异步请求,由异步客户端当总超时用,从连接一直管到 body 读完:
let r = c.get(&trickle).timeout(one_s).send().and_then(|mut r| r.copy_to(&mut Vec::new()));
RequestBuilder timeout(1s), copy_to 1.00s Err: request or response body error | is_timeout=true
二:整个 Client 都要总超时,就在异步 builder 上设,再转成阻塞的。 From<async_impl::ClientBuilder> 收下配好的异步 builder:
let c2 = ClientBuilder::from(reqwest::Client::builder().no_proxy().timeout(one_s)).build().unwrap();
From(async timeout(1s)), copy_to 1.00s Err: request or response body error | is_timeout=true
From(async timeout(1s)), send+text 1.00s Err: error decoding response body for url (http://127.0.0.1:52249/?head_ms=700&chunks=2&size=1&body_ms=700) | is_timeout=true
两个慢请求都在 1.00 秒失败。这个 From 会把阻塞层自己的每段上限设回默认的 30 秒,但异步那一侧的 1 秒总超时先到。用同样的办法还能补上 read_timeout(注意 0.13.5 的 read_timeout 有 把处理时间算进去 的问题)。
错误的样子也变了:copy_to 失败是 request or response body error(Body 种类,没有 URL),text() 失败是 error decoding response body for url (...),两者 is_timeout() 都为真。判断超时用 is_timeout(),不要看文字。
怎么检查
- 用一个按固定间隔吐字节的本地服务器(
testsrv的?chunks=&body_ms=),间隔小于设的超时、总长大于它,看请求是不是成功了。 - 代码里搜
blocking::Client::builder().timeout(而请求上没有.timeout(的地方,那里的超时都是按段的。
小结
blocking::ClientBuilder::timeout(默认 30 秒)是每次等待的上限:等头一次、text()一次、Read::read每次一次,各自从零计。- 慢慢吐字节的 body 每段都不超时,总时长没有上限;
send加text也可以超过设的值。 - 修法:
RequestBuilder::timeout给单个请求总超时;或者在异步 builder 上设timeout,再blocking::ClientBuilder::from转过来。 - 判断超时用
is_timeout(),两种修法下的错误文字不一样。