Rust in Everything
基于 reqwest 0.13.5

阻塞客户端的超时按段计,慢 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(),两种修法下的错误文字不一样。

💬 关联讨论

发起讨论
加载中...