Rust in Everything
基于 tower-http 0.7.1

超时一族

主线位置:Timeout 在 call 时开始计时、在响应 future 完成前把「没有响应」变成一个响应;四个 body 计时器在 call 时(请求 body)或响应 future 完成时(响应 body)给 body 套一层,真正计时在 body 流动时。TimeoutLayer 在 axum 里的行为、为什么超时和内层同时到期时超时赢,见 axum 卷 其余常用中间件。

flowchart TB
  subgraph head["到响应头为止"]
    t["Timeout:call 时建 Sleep,先 poll Sleep 再 poll 内层,到点回一个空 body 响应"]
  end
  subgraph reqb["请求 body:call 时套上"]
    rbt["RequestBodyTimeout → TimeoutBody"]
    rbd["RequestBodyDeadline → DeadlineBody"]
  end
  subgraph resb["响应 body:future 完成时套上"]
    sbt["ResponseBodyTimeout → TimeoutBody"]
    sbd["ResponseBodyDeadline → DeadlineBody"]
  end
  rbt --> err["poll_frame 返回 Err(TimeoutError)"]
  rbd --> err
  sbt --> err
  sbd --> err

五个类型

timeout 模块导出五个中间件(各配一个 Layer)和两个 body:

中间件套在哪计时从何时开始何时重置到点后
Timeout整个响应 futurecall不重置返回一个状态码自选、空 body 的响应
RequestBodyTimeout请求 body(TimeoutBody)handler 第一次 poll 请求 body每交出一帧读 body 的一方拿到 TimeoutError
ResponseBodyTimeout响应 body(TimeoutBody)调用方第一次 poll 响应 body每交出一帧同上
RequestBodyDeadline(0.7.0)请求 body(DeadlineBody)call不重置同上
ResponseBodyDeadline(0.7.0)响应 body(DeadlineBody)响应 future 完成不重置同上

请求方向的两个只是在 call 里 req.map(...),不需要自己的 future,type Future = S::Future(RequestBodyTimeout::call)。响应方向的两个各有一个只做 res.map(...) 的 future(ResponseBodyTimeoutFuture)。四个都不要求 body 实现 Default,因为它们不造响应。

Timeout 要求 ResBody: Default,到点时 Response::new(B::default())(第 141 行)。它的构造函数有两套:TimeoutLayer::new(timeout) 固定回 408,0.6.7 起标了 #[deprecated(since = "0.6.7", note = "Use `TimeoutLayer::with_status_code` instead")](第 29 行,#599);with_status_code(status, timeout) 自选状态码。

TimeoutBody:第一次 poll 才开始,每帧重开

TimeoutBody::poll_frame:

        // Start the `Sleep` if not active.
        let sleep_pinned = if let Some(some) = this.sleep.as_mut().as_pin_mut() {
            some
        } else {
            this.sleep.set(Some(sleep(*this.timeout)));
            this.sleep.as_mut().as_pin_mut().unwrap()
        };

        // Error if the timeout has expired.
        if let Poll::Ready(()) = sleep_pinned.poll(cx) {
            return Poll::Ready(Some(Err(Box::new(TimeoutError(())))));
        }

        // Check for body data.
        let frame = ready!(this.body.poll_frame(cx));
        // A frame is ready. Reset the `Sleep`...
        this.sleep.set(None);

sleep 是 Option<Sleep>,构造时是 None。计时器在 poll_frame 第一次被调用时才建,每拿到一帧就扔掉,下次 poll 再建一个新的。所以它量的是「调用方在等,而数据迟迟不来」的那段时间:两帧之间调用方自己没去 poll 的时间不算。它是一个空闲超时,不是总时长;仓库里有一个专门的文档提交(#574,「Clarify TimeoutBody is a timeout per frame, not total」)说明这一点。

后果就是 0.7.0 加 DeadlineBody 的理由:一个每 50 毫秒发一个字节的客户端,永远不会触发 100 毫秒的 TimeoutBody,一个 10 MiB 的上传能拖上好几天。

DeadlineBody:套上就开始

DeadlineBody(0.7.0,#688)在构造时就建好 Sleep:

    pub fn new(timeout: Duration, body: B) -> Self {
        DeadlineBody {
            sleep: sleep(timeout),
            body,
        }
    }

poll_frame 先查 sleep 再查内层,和 TimeoutBody 一样,只是从不重置。「构造时」对请求 body 是 call 的那一刻,对响应 body 是响应 future 完成的那一刻。计时和有没有人在读无关:响应头交出去以后调用方迟迟不读 body,时间照样在走;到点之后哪怕数据就在缓冲里,第一次 poll 拿到的也是错误。

两者可以叠:一个空闲超时防「连接挂着不动」,一个总时限防「一个字节一个字节地拖」。

错误:TimeoutError

两个 body 的错误类型都是 Box<dyn std::error::Error + Send + Sync>,超时时装的是 TimeoutError,Display 是 data was not received within the designated timeout,可以 downcast 判断。内层 body 自己的错误经 Into<BoxError> 原样透出。

这是一个 body 错误,不是响应:

  • 请求方向,错误交给读请求 body 的那一方(handler、提取器),回什么由它决定。
  • 响应方向,响应头已经发出去了,没法再换成一个错误响应,服务端只能中断这个响应,客户端读到的是一个不完整的 body。

TimeoutBody 不转发 size_hint

TimeoutBody 的 Body 实现里只有 poll_frame,size_hint 和 is_end_stream 用的是 trait 的默认值:长度未知、永不提前结束。DeadlineBody 两个都转发了。

对响应来说这有实际后果:hyper 在响应没写 Content-Length 头时,靠 body 的 size_hint().exact() 决定发 Content-Length 还是用 chunked 编码。套上 ResponseBodyTimeoutLayer 以后长度信息丢了,原本 5 字节的响应就变成 chunked。下面一个只服务一条连接的 hyper 服务器(只绑 127.0.0.1,发完即关),handler 回 Full::from("hello"),不写任何头:

tower-http = { version = "=0.7.1", features = ["timeout"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "net", "io-util", "time"] }
hyper = { version = "1", features = ["server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio", "service"] }
http = "1"
http-body-util = "0.1"
bytes = "1"
async fn hello(_: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
  Ok(Response::new(Full::from("hello")))
}

// probe!(label, svc):在 127.0.0.1:0 上 accept 一条连接,用 hyper http1 + TowerToHyperService 服务它;
// 客户端用裸 TcpStream 发 "GET / HTTP/1.1\r\nhost: x\r\nconnection: close\r\n\r\n",
// 读到 EOF,打印 content-length / transfer-encoding 两行

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let t = Duration::from_secs(5);
  probe!("plain", tower::service_fn(hello));
  probe!("ResponseBodyTimeout", ResponseBodyTimeoutLayer::new(t).layer(tower::service_fn(hello)));
  probe!("ResponseBodyDeadline", ResponseBodyDeadlineLayer::new(t).layer(tower::service_fn(hello)));
}

输出(hyper 1.12.0,跑 3 次相同):

plain                ["content-length: 5"]
ResponseBodyTimeout  ["transfer-encoding: chunked"]
ResponseBodyDeadline ["content-length: 5"]

实验:空闲超时与总时限

暂停时钟,毫秒数是 tokio 的虚拟时间。请求 body 用两种节奏:每 50 毫秒一个字节共 10 个(500 毫秒),以及 50、50、150 毫秒三个字节(中间停了 150 毫秒,共 250 毫秒)。响应 body 是现成的 hello,但调用方拿到响应 200 毫秒后才开始读:

tower-http = { version = "=0.7.1", features = ["timeout"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "time", "test-util"] }
http = "1"
http-body = "1"
http-body-util = "0.1"
bytes = "1"
futures = "0.3"
type Body = UnsyncBoxBody<Bytes, BoxError>;

// 每隔 gaps[i] 毫秒发一个字节
fn trickle(gaps: Vec<u64>) -> Body {
  let s = futures::stream::unfold(gaps.into_iter(), |mut it| async move {
    let gap = it.next()?;
    sleep(Duration::from_millis(gap)).await;
    Some((Ok::<_, BoxError>(Frame::data(Bytes::from_static(b"x"))), it))
  });
  StreamBody::new(s).boxed_unsync()
}

// handler 读请求 body,报告结果和读完的时刻;出错时检查 e.is::<TimeoutError>()
async fn read_req<B>(req: Request<B>) -> Result<Response<Full<Bytes>>, Infallible>
where
  B: http_body::Body,
  B::Error: Into<BoxError>,
{ /* ... */ }

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
  let even = vec![50; 10];
  let gap = vec![50, 50, 150];
  println!("request body");
  for (name, gaps) in [("even 10x50ms", even.clone()), ("one 150ms gap", gap.clone())] {
    let svc = RequestBodyTimeoutLayer::new(Duration::from_millis(100)).layer(tower::service_fn(read_req::<_>));
    // oneshot(Request::new(trickle(gaps.clone()))),打印 handler 的回报
    let svc = RequestBodyDeadlineLayer::new(Duration::from_millis(300)).layer(tower::service_fn(read_req::<_>));
    // 同上
  }

  println!("response body, read after 200 ms");
  let ok = |_: Request<()>| async { Ok::<_, Infallible>(Response::new(Full::<Bytes>::from("hello"))) };
  let res = ResponseBodyTimeoutLayer::new(Duration::from_millis(100)).layer(tower::service_fn(ok)).oneshot(Request::new(())).await.unwrap();
  println!("  timeout  size_hint.exact={:?}", res.body().size_hint().exact());
  sleep(Duration::from_millis(200)).await;
  println!("  timeout  100ms -> {:?}", res.into_body().collect().await.map(|c| c.to_bytes()));
  // ResponseBodyDeadlineLayer 同样走一遍
}

输出(M2 Max、rustc 1.99.0,跑 3 次相同):

request body
  timeout  100ms, even 10x50ms  -> read 10 bytes at 500 ms
  deadline 300ms, even 10x50ms  -> error (TimeoutError: true): data was not received within the designated timeout at 300 ms
  timeout  100ms, one 150ms gap -> error (TimeoutError: true): data was not received within the designated timeout at 200 ms
  deadline 300ms, one 150ms gap -> read 3 bytes at 250 ms
response body, read after 200 ms
  timeout  size_hint.exact=None
  timeout  100ms -> Ok(b"hello")
  deadline size_hint.exact=Some(5)
  deadline 100ms -> Err("data was not received within the designated timeout")
  • 匀速慢传:100 毫秒的空闲超时永远等不到,500 毫秒读完;300 毫秒的总时限在第 300 毫秒切断。这就是 #688 说的「一个字节一个字节慢慢发的客户端只会触发后者」。
  • 中间停一下:空闲超时在第三个字节前等了 100 毫秒就报错(50 + 50 + 100 = 200 毫秒);总时限 300 毫秒内读完了 250 毫秒的 body。
  • 晚读的响应:TimeoutBody 的计时器从第一次 poll 才开始,晚读 200 毫秒也没事;DeadlineBody 从响应 future 完成就开始计时,数据明明就在,第一次 poll 还是拿到超时。size_hint 一个丢了、一个保留。

小结

  • 五个类型:Timeout 管到响应头为止,到点回一个空 body 响应(new 的 408 在 0.6.7 弃用);四个 body 计时器管 body,到点让 poll_frame 返回 TimeoutError。
  • TimeoutBody 是空闲超时:第一次 poll 才建 Sleep,每帧之后重建,调用方不 poll 的时间不算。
  • DeadlineBody(0.7.0,#688)是总时限:构造时建 Sleep,从不重置,请求方向从 call 算起,响应方向从响应 future 完成算起,和有没有人读无关。
  • 超时是 body 错误:请求方向由读 body 的一方处置,响应方向只能中断这个响应。
  • TimeoutBody 不转发 size_hint 和 is_end_stream,套上 ResponseBodyTimeoutLayer 的响应在 hyper 下从 Content-Length 变成 chunked;DeadlineBody 转发了两者。

💬 关联讨论

发起讨论
加载中...