超时
主线位置:横跨请求主线、连接主线和响应主线。三个超时没有一个是 tower 层:总超时和读超时是
PendingRequest里的两个Sleep,响应头到了以后转进响应体的两个包装;connect_timeout在连接器里。本页按「服务器卡在哪一段」把它们放在一起比。
flowchart TB s["send() 起算"] --> c["连接 DNS TCP TLS"] c --> h["等响应头"] h --> b["读响应体"] ct["connect_timeout"] -.-> c tt["总超时 一个截止"] -.-> c tt -.-> h tt -.-> b r1["读超时 头前截止"] -.-> c r1 -.-> h r2["读超时 每帧重置"] -.-> b
三个设置都在 ClientBuilder 上,默认都是 None:Client::new() 发出的请求遇到一个永远不回话的服务器,就永远不结束(连接器一页用 5 秒的外部超时实测过)。阻塞客户端不一样,它默认有 30 秒总超时,见 阻塞客户端。
| 设置 | 实现位置 | 起算 | 覆盖 |
|---|---|---|---|
timeout | PendingRequest 的 total_timeout,头到后移进 TotalTimeoutBody | send() | 从连接到 body 读完,一个截止时间 |
read_timeout | PendingRequest 的 read_timeout_fut;ReadTimeoutBody | send();之后每帧重新计 | 头到之前是一个截止时间;body 里是两帧之间的间隔 |
connect_timeout | hyper-util HttpConnector;ConnectorService 外面一层 | 连接器被调用时 | 只有建连接:DNS、TCP、代理、TLS |
另外 RequestBuilder::timeout 可以给单个请求覆盖总超时(存在请求的 extensions 里,execute_request 用 RequestConfig::fetch 先取请求的、再取客户端的)。读超时和连接超时没有按请求覆盖的办法。
总超时:一个计时器数到底
execute_request 里用 tokio::time::sleep(d) 建好 Sleep,截止时间在 send() 时就定了;PendingRequest::poll 每次先看它,到点就返回 request(TimedOut)。这两段代码在 执行与 Pending 讲过。响应头到了以后,Response::new 用 take() 把同一个 Sleep 拿走,交给 TotalTimeoutBody:
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context,
) -> Poll<Option<Result<hyper::body::Frame<Self::Data>, Self::Error>>> {
let this = self.project();
if let Poll::Ready(()) = this.timeout.as_mut().poll(cx) {
return Poll::Ready(Some(Err(crate::error::body(crate::error::TimedOut))));
}
Poll::Ready(
ready!(this.inner.poll_frame(cx))
.map(|opt_chunk| opt_chunk.map_err(crate::error::body)),
)
}
每次拉一帧都先查截止时间,过了就报错,哪怕下一帧的数据已经在缓冲里。结构体上的注释说得很直接:「The timeout does not reset upon each chunk」。所以总超时量的是从 send() 到 body 读完的全部时间,包括调用方自己在两次 chunk() 之间花掉的时间。
读超时:前后两半不一样
read_timeout 的文档说它「applies to each read operation, and resets after a successful read」。这只说对了后一半。
响应头之前:execute_request 和总超时一样建一个 Sleep(read_timeout_fut),PendingRequest::poll 用同样的方式检查,从不重置。所以在响应头到达之前,读超时就是一个从 send() 起算的截止时间:DNS、TCP、TLS、发请求、等服务器处理,全算在里面。它和总超时在这一段的行为完全相同。
响应体里:Response::new 传给 body 的是 Duration,不是那个 Sleep。ReadTimeoutBody 自己管计时器:
let deadline = tokio::time::Instant::now() + *this.timeout;
let sleep_pinned = if let Some(some) = this.sleep.as_mut().as_pin_mut() {
some
} else {
this.sleep.set(Some(tokio::time::sleep_until(deadline)));
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(crate::error::body(crate::error::TimedOut))));
}
let item = ready!(this.inner.poll_frame(cx))
.map(|opt_chunk| opt_chunk.map_err(crate::error::body));
// a ready frame means timeout is reset for the next chunk
if let Some(some) = this.sleep.as_mut().as_pin_mut() {
some.reset(tokio::time::Instant::now() + *this.timeout);
}
Poll::Ready(item)
第一次 poll 时建 Sleep;每拿到一帧,把它重置到「现在 + timeout」。两帧之间隔得太久就报错。
两个都设时,body::response 先套读超时、再套总超时,总超时在外面先检查。
连接超时
connect_timeout 一份值用在两处:hyper-util HttpConnector 的 TCP 超时(按解析出的地址数平分),和包住整个连接器调用的外层超时。细节和实测都在 连接器 的「connect_timeout 管两处」,这里只用结论:它只管建连接,连接一旦交给池子就和它无关;从池里复用的连接根本不经过它。
三个阶段各卡住一次
服务器分三种卡法:
- 握手:接受 TCP,读走 ClientHello,永远不回(自己写的一个只
accept不说话的服务器,URL 用https://); - 响应头前:
testsrv的?head_ms=1000,1 秒后才回头; - 响应体中间:
testsrv的?chunks=3&size=16&body_ms=1000,头和第一块立刻发,之后每块隔 1 秒。
每种卡法配四个客户端:只设 connect_timeout(300ms)、只设 read_timeout(300ms)、只设 timeout(300ms)、什么都不设。外面套一个 2 秒的 tokio::time::timeout 兜底,记录在 send() 还是 bytes() 失败、用了多久、is_* 哪些为真、错误链:
async fn run(label: &str, b: reqwest::ClientBuilder, url: &str) {
let c = b.no_proxy().build().unwrap();
let t = Instant::now();
let work = async {
let r = c.get(url).send().await.map_err(|e| ("send", e))?;
let at_head = t.elapsed();
r.bytes().await.map(|b| (at_head, b.len())).map_err(|e| ("bytes", e))
};
match tokio::time::timeout(ms(2000), work).await {
// ... 打印三种结果
}
}
开始前先发一个请求预热(进程里第一次用 aws-lc-rs 要多花约 26 ms)。连跑 3 次,失败时刻都在 300–304ms,触发的超时和 is_* 每次相同(运行时 uptime 负载约 15,本机同时有别的编译任务)。第一次的输出:
== TLS handshake never answered
connect_timeout(300ms) send() failed at 303ms is_[request,connect,timeout]
error sending request for url (https://127.0.0.1:62901/)
<- client error (Connect) [hyper_util::client::legacy::Error]
<- operation timed out [Debug: TimedOut]
read_timeout(300ms) send() failed at 303ms is_[request,timeout]
error sending request for url (https://127.0.0.1:62901/)
<- operation timed out [Debug: TimedOut]
timeout(300ms) send() failed at 302ms is_[request,timeout]
error sending request for url (https://127.0.0.1:62901/)
<- operation timed out [Debug: TimedOut]
none still pending after 2000ms
== head after 1000ms
connect_timeout(300ms) ok: head at 1004ms, 46 B at 1004ms
read_timeout(300ms) send() failed at 302ms is_[request,timeout]
error sending request for url (http://127.0.0.1:62900/?head_ms=1000)
<- operation timed out [Debug: TimedOut]
timeout(300ms) send() failed at 303ms is_[request,timeout]
error sending request for url (http://127.0.0.1:62900/?head_ms=1000)
<- operation timed out [Debug: TimedOut]
none ok: head at 1006ms, 46 B at 1006ms
== body stalls 1000ms after chunk 1
connect_timeout(300ms) still pending after 2000ms
read_timeout(300ms) bytes() failed at 304ms is_[timeout,decode]
error decoding response body for url (http://127.0.0.1:62900/?chunks=3&size=16&body_ms=1000)
<- request or response body error [reqwest::Error]
<- operation timed out [Debug: TimedOut]
timeout(300ms) bytes() failed at 301ms is_[timeout,decode]
error decoding response body for url (http://127.0.0.1:62900/?chunks=3&size=16&body_ms=1000)
<- request or response body error [reqwest::Error]
<- operation timed out [Debug: TimedOut]
none still pending after 2000ms
方括号里是每一层的类型;TimedOut 是 reqwest 的私有类型,只能从 Debug 认出来。
- 握手卡住:三个超时都在约 300ms 触发,
is_timeout()都为真。只有connect_timeout的错误链里有 hyper-util 的client error (Connect),is_connect()为真;另外两个是PendingRequest直接返回的request(TimedOut),链只有两层。读超时管住了握手,正是上面「响应头之前是一个截止时间」的结果。 - 响应头前卡住:
connect_timeout不管,请求在 1004ms 正常完成;读超时和总超时在 300ms 触发,错误和握手卡住时一模一样,只看错误分不出卡在哪一段。 - 响应体中间卡住:
send()已经成功,错误在bytes()里出现。种类是Decode,不是Body:超时包装产生的是body(TimedOut),do_bytes又在外面包了一层decode(种类判断见 错误)。connect_timeout对 body 无能为力,2 秒后仍在等。
慢慢来的 body
读超时和总超时在 body 上的区别:服务器发 8 块,每块隔 200ms,整个 body 约 1.4 秒:
== trickle: 8 chunks, 200ms apart
read_timeout(300ms) ok: head at 1ms, 128 B at 1417ms
timeout(1000ms) bytes() failed at 1002ms is_[timeout,decode]
error decoding response body for url (http://127.0.0.1:62900/?chunks=8&size=16&body_ms=200)
<- request or response body error [reqwest::Error]
<- operation timed out [Debug: TimedOut]
读超时 300ms 从不触发,因为每 200ms 就来一帧;总超时 1 秒在第 1002ms 掐断了一个正常但慢的下载。下载大文件这类 body 大小事先不知道的请求,用读超时检测「卡死」,总超时只能按最坏情况设得很宽。两者可以同时设。
read_timeout(200ms)。服务器一次就把 4 块 16 字节的 chunked body 全发完了。调用方用 chunk() 一块块读,每读到一块先 sleep(300ms) 模拟处理(比如写一个慢磁盘)再读下一块。4 块能全读到吗?平台:M2 Max、macOS 27,reqwest 0.13.5,release 构建,服务器是本机 testsrv(?chunks=4&size=16)。
其他和时间有关的设置
下面这些都不限制一个请求能等多久,列出来是为了和上面三个区分:
pool_idle_timeout(默认 90 秒):空闲连接在池里留多久,见 连接池与 HTTP 版本。http2_keep_alive_interval/http2_keep_alive_timeout:HTTP/2 的 PING 保活,默认关闭。- TCP keepalive:默认 15 秒开始探测、间隔 15 秒、3 次(
tcp_keepalive、tcp_keepalive_interval、tcp_keepalive_retries)。 tcp_user_timeout:只在 Linux、Android、Fuchsia 上存在,默认 30 秒,控制「已发出的数据多久没被确认就强制关连接」。这几个默认值是 #2780(「fix: add default tcp keepalive and user_timeout values」,6358cef,首次发布在 v0.12.23)加的。它们只能发现对端主机或网络消失;对端进程活着、只是不回 HTTP 响应时,TCP 层一切正常,它们不会触发。本机是 macOS,tcp_user_timeout没有实测。
动脑筋答案
read_timeout(200ms), consumer sleeps 100ms per chunk: all 4 chunks read (411ms)
read_timeout(200ms), consumer sleeps 300ms per chunk: chunk #2 failed: is_[timeout,decode]
error decoding response body
<- request or response body error [reqwest::Error]
<- operation timed out [Debug: TimedOut] (302ms)
读不全,第 2 块就失败了。 数据早就在客户端的缓冲里,服务器一点也没卡,但第一块交出去时 ReadTimeoutBody 把计时器重置到「交出时刻 + 200ms」;调用方睡了 300ms 才来要第二块,poll_frame 先查计时器,已经过期,直接报超时。睡 100ms 时 4 块都能读到。连跑 3 次结果相同。(错误里没有 for url (...),因为 chunk() 不带 URL。)
同样的服务器换成 reqwest 0.12.28(exp-reqwest/resp-012,一个直接写 TCP 字节的服务器,一次发完 4 块):
reqwest 0.12.28 read_timeout(200ms), consumer sleeps 100ms per chunk: all 4 chunks read (409ms)
reqwest 0.12.28 read_timeout(200ms), consumer sleeps 300ms per chunk: all 4 chunks read (1213ms)
用同一个 TCP 服务器跑 0.13.5,结果和上面 testsrv 的一样:300ms 那组在第 2 块失败(chunk #2 failed: error decoding response body timeout=true (302ms))。区别来自 0.13.5 里的 #3085(「perf(body): reuse tokio::time::Sleep timer via reset() in ReadTimeoutBody」,ffda263,首次发布在 v0.13.5,CHANGELOG 没有提到)。改之前,拿到一帧后执行的是 this.sleep.set(None):计时器被清掉,下一次 poll 才新建,从「调用方回来要下一块」开始计,调用方的处理时间不算。改之后为了复用同一个 Sleep,在交出这一帧时就 reset(now + timeout),计时从「交出时刻」开始,调用方的处理时间也算进去了。提交标题是性能优化,行为却变了:读超时现在量的不再只是服务器的沉默,消费方慢也会触发。tag 之后 master 上 src/async_impl/body.rs 没有新提交,这段代码仍是这样。
小结
- 三个超时默认都是
None,没有一个是 tower 层。RequestBuilder::timeout只能覆盖总超时。 - 总超时:
send()时建好的一个Sleep,头前在PendingRequest里查,头到后整个移进TotalTimeoutBody,到 body 读完一直是同一个截止时间,每拉一帧先查它。 - 读超时:响应头之前是从
send()起算、不重置的截止时间,管住连接和等头;响应体里由ReadTimeoutBody按帧重置。 connect_timeout只管建连接(DNS、TCP、代理、TLS),对等头和读 body 不起作用;只有它的错误is_connect()为真。- 卡在头前,读超时和总超时的错误一模一样;卡在 body 里,错误种类是
Decode,is_timeout()为真。 - 慢而不停的下载用读超时;总超时会掐断它。
- 0.13.5 起,读超时把调用方在两次
chunk()之间的处理时间也算进去(#3085),0.12.28 不算。