Rust in Everything
基于 reqwest 0.13.5

只设了 connect_timeout,请求还是卡住

主线位置:横跨请求主线和响应主线。三种超时各自在哪里实现、从哪一刻起算、各管哪一段,见 超时;connect_timeout 在连接器里的两处用法见 连接器。这一页只看一个配置错误:以为设了 connect_timeout 就有了超时。

flowchart TB
  s["send"] --> c["建连接"]
  c --> h["等响应头"]
  h --> b["读响应体"]
  ct["connect_timeout"] -.-> c
  rt["read_timeout"] -.-> h
  rt -.-> b
  tt["timeout 总超时"] -.-> c
  tt -.-> h
  tt -.-> b
  h --> hang1["服务器不回 永远等"]
  b --> hang2["发到一半 永远等"]

实验 crate:exp-reqwest/pita-lab,src/bin/p03_timeout.rs。服务端是本机 testsrv:?head_ms=60000 60 秒后才发响应头;?chunks=3&size=16&body_ms=60000 立刻发头和第一块,之后每块隔 60 秒。客户端外面套一个 3 秒的 tokio::time::timeout 代表「永远」。M2 Max、macOS 27、rustc 1.99.0,release。

现象

四种客户端配置,各打两种卡法:

run("Client::new() (defaults)", reqwest::Client::builder(), &url).await;
run("connect_timeout(1s)", reqwest::Client::builder().connect_timeout(ms(1000)), &url).await;
run("connect_timeout(1s)+read_timeout(1s)", reqwest::Client::builder().connect_timeout(ms(1000)).read_timeout(ms(1000)), &url).await;
run("connect_timeout(1s)+timeout(2s)", reqwest::Client::builder().connect_timeout(ms(1000)).timeout(ms(2000)), &url).await;

(run 里每个 builder 都先 .no_proxy() 再 build(),然后 send() 加 bytes()。)

== server waits 60 s before the head
  Client::new() (defaults)             still pending after 3002ms
  connect_timeout(1s)                  still pending after 3003ms
  connect_timeout(1s)+read_timeout(1s) send() failed at 1002ms: error sending request for url (http://127.0.0.1:50300/?head_ms=60000) | is_timeout=true
  connect_timeout(1s)+timeout(2s)      send() failed at 2003ms: error sending request for url (http://127.0.0.1:50300/?head_ms=60000) | is_timeout=true
== head + 1 chunk, then 60 s per chunk
  Client::new() (defaults)             still pending after 3003ms
  connect_timeout(1s)                  still pending after 3002ms
  connect_timeout(1s)+read_timeout(1s) bytes() failed at 1003ms: error decoding response body for url (http://127.0.0.1:50300/?chunks=3&size=16&body_ms=60000) | is_timeout=true
  connect_timeout(1s)+timeout(2s)      bytes() failed at 2002ms: error decoding response body for url (http://127.0.0.1:50300/?chunks=3&size=16&body_ms=60000) | is_timeout=true

前两行就是这个坑。本机的连接瞬间就建好了,connect_timeout(1s) 根本没机会触发;之后服务器不回话,请求就和默认配置一样一直挂着。在真实服务里,它表现为一个永远不返回的任务,或者一个越积越多的并发数。

原因

默认值全是 None。 ClientBuilder::new 里 connect_timeout: None、read_timeout: None、timeout: None。异步 Client 不会替你决定一个请求最多等多久。

connect_timeout 只包住连接器。 connect_timeout 的值交给 hyper-util 的 HttpConnector(TCP)和包在整个连接器调用外面的一层超时。连接一旦建好交给 hyper,它就不再起作用;从池里复用的连接连这一步都不经过。

能管住响应的只有另外两个。 timeout 是从 send() 起算的一个截止时间,头到之前在 PendingRequest 里检查,头到之后移进 TotalTimeoutBody。read_timeout 在头到之前也是从 send() 起算的截止时间,进了 body 以后由 ReadTimeoutBody 每收到一帧重置一次。上面的输出里两者都在设定的时间点报错,is_timeout() 为真;卡在 body 时错误出现在 bytes(),种类是 Decode(原因见 错误)。

TCP keepalive(默认 15 秒开始探测)帮不上忙:服务器进程活着、只是不回 HTTP 响应时,TCP 层一切正常。

📝NOTE
冷知识:read_timeout 是 0.12.4 才有的

#2241「feat: add ClientBuilder::read_timeout(dur)」(1af8945,首次发布在 v0.12.4)。在那之前,异步客户端能限制响应的只有总超时 timeout:下载一个大小不知道的 body,要么把总超时设得很宽,要么冒着掐断正常慢下载的风险。这个提交给 PendingRequest 加了 read_timeout_fut,给 body 加了按帧重置的包装,新方法的文档写着「This is more appropriate for detecting stalled connections when the size isn't known beforehand」;同时把 timeout 的文档从「Enables a request timeout」改成「Enables a total request timeout」,加了一句「Also considered a total deadline」。上面实验里第三行的 read_timeout(1s) 就是它。

修法

按请求的性质选,两个可以一起设:

  • 接口调用(响应小、应该很快): timeout(...),一个总截止时间覆盖连接、等头、读 body。上面第四行,2 秒整报错。单个请求要更长,用 RequestBuilder::timeout 覆盖。
  • 下载、流式响应(总时长不定): read_timeout(...),只要数据在流动就不触发,停住超过设定值才报错。上面第三行,1 秒报错。注意 0.13.5 里它也把调用方处理每块的时间算了进去,见 read_timeout 算上了调用方的时间。
  • connect_timeout 可以留着,它让「连不上」比「连上了不回」更早失败,错误里还有 is_connect() 可以区分,但它不能代替前两个。

阻塞客户端不一样:它默认有 30 秒超时,而且按阶段计,见 阻塞客户端。

怎么检查

  • 搜代码: 每一处 Client::builder() 都应该能看到 .timeout( 或 .read_timeout(;只有 .connect_timeout( 的就是这个坑,Client::new() 则是什么都没有。

  • 打印配置: Client 的 Debug 只在设了的时候带上这两个字段(src/bin/p03_debug.rs):

    defaults:        Client { accepts: Accepts { gzip: true }, referer: true, default_headers: {"accept": "*/*"} }
    connect_timeout: Client { accepts: Accepts { gzip: true }, referer: true, default_headers: {"accept": "*/*"} }
    all three:       Client { accepts: Accepts { gzip: true }, referer: true, default_headers: {"accept": "*/*"}, reqwest::config::TotalTimeout: 2s, read_timeout: 1s }
    

    总超时打印成 reqwest::config::TotalTimeout,不叫 timeout;connect_timeout 在 Client 的 Debug 里根本不出现(它只在 ClientBuilder 的 Debug 里),所以前两行看起来一模一样。

  • 写一个卡住的测试: 测试服务器在响应头前 sleep 一个远大于预期的时间,断言请求在超时附近失败、is_timeout() 为真;再测一次「头立刻发、body 卡住」,两段都要覆盖。

小结

  • 异步 Client 默认三种超时都没有,服务器不回话时请求永远不结束。
  • connect_timeout 只管建连接;连接建好以后服务器卡在响应头之前或 body 中间,它都不管。
  • 要管住响应,用 timeout(总截止时间)或 read_timeout(数据停止流动多久算卡死),实测分别在设定值处报 is_timeout()。
  • 卡在 body 时错误出现在 bytes() / text() / json(),种类是 Decode。

💬 关联讨论

发起讨论
加载中...