Rust in Everything
基于 reqwest 0.13.5

error sending request for url (...)

主线位置:请求主线的出口。send().await 返回的 reqwest::Error 只是一个外壳,真正的原因在 source() 链里:hyper-util、hyper、io、rustls 一层套一层。Kind、is_* 和每种失败的完整链见 错误。本页讲日志里只打 {e} 的后果和修法。

flowchart TB
  e["reqwest Error"] -->|"Display"| one["只有一句"]
  e -->|"source"| hu["hyper-util Error"]
  hu -->|"source"| io["io Error"]
  io -->|"source 跳过"| io2["内层 io Error"]
  io2 -.->|"get_ref"| rl["rustls Error"]
  one --> log["日志看不出原因"]

实验 crate:exp-reqwest/pitb-lab(src/bin/errdisp.rs)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,客户端 .no_proxy().timeout(300ms)。服务器是 testsrv:明文一个、TLS 一个(客户端不信它的 CA),再加一个刚关掉的端口。

现象

四种完全不同的失败:端口没人听、证书由不认识的 CA 签发、服务器 2 秒后才回响应头(客户端总超时 300 毫秒)、服务器读完请求直接断开(?abort=1)。照最常见的写法打日志:

  for (label, url) in &cases {
    let e = c.get(url).send().await.unwrap_err();
    println!("[{label}] {e}");
    errs.push((label, e));
  }
[refused] error sending request for url (http://127.0.0.1:50276/)
[unknown CA] error sending request for url (https://localhost:50275/)
[slow server] error sending request for url (http://127.0.0.1:50274/?head_ms=2000)
[closed early] error sending request for url (http://127.0.0.1:50274/?abort=1)

四行除了 URL 一模一样。拿到这种日志,分不清是对方挂了、证书过期、网络慢,还是对方把连接掐了。

原因

Display for Error 按 Kind 写一句固定的话,再加 for url (...),不写 source。这是 Rust 错误类型的通行约定:Display 只描述自己这一层,下一层交给 source(),由打印的人决定要不要往下走。hyper 和 hyper-util 也这样。reqwest 0.12.0 曾经把 source 接在后面,0.12.1 删掉了,经过见 错误 里的冷知识。

沿着 source() 走到底,四种失败就分开了:

[refused] chain: error sending request for url (http://127.0.0.1:50276/) <- client error (Connect) <- tcp connect error <- Connection refused (os error 61)
[unknown CA] chain: error sending request for url (https://localhost:50275/) <- client error (Connect) <- invalid peer certificate: UnknownIssuer
[slow server] chain: error sending request for url (http://127.0.0.1:50274/?head_ms=2000) <- operation timed out
[closed early] chain: error sending request for url (http://127.0.0.1:50274/?abort=1) <- client error (SendRequest) <- connection closed before message completed

证书那一条还有第二层麻烦。链的最后一环 invalid peer certificate: UnknownIssuer 是一个 std::io::Error,不是 rustls::Error。rustls 的错误先被 tokio-rustls 包成 io::Error(InvalidData),hyper-rustls 再用 io::Error::other 包一层;io::Error::source() 返回的是内层错误的 source(),跳过了内层错误本身,所以沿着 source() 用 downcast_ref::<rustls::Error>() 永远找不到它。文字还在(Display 一路透传),类型丢了。

修法

日志里打印整条链。 自己写一个循环:

pub fn chain(e: &dyn Error) -> String {
  let mut s = e.to_string();
  let mut src = e.source();
  while let Some(x) = src {
    s += &format!(" <- {x}");
    src = x.source();
  }
  s
}

上面那段 chain: 输出就是它打的。已经用 anyhow 的项目,把 reqwest::Error 转成 anyhow::Error,用 {:#} 或 {:?}:

  anyhow {:#}: error sending request for url (https://localhost:50275/): client error (Connect): invalid peer certificate: UnknownIssuer
  anyhow {:?}:
error sending request for url (https://localhost:50275/)

Caused by:
    0: client error (Connect)
    1: invalid peer certificate: UnknownIssuer

注意不要用 format!("请求失败: {e}") 把错误变成字符串再往上传:字符串没有 source(),链在这一步就断了,上层再怎么打印也找不回来。要加上下文,用 anyhow 的 context 或者自己的错误类型把 reqwest::Error 存为 source。

程序里要分支,用 is_* 和按类型沿链找,不要匹配字符串。

fn find<T: Error + 'static>(e: &reqwest::Error) -> Option<&T> {
  let mut cur = e.source();
  while let Some(s) = cur {
    if let Some(t) = s.downcast_ref::<T>() {
      return Some(t);
    }
    cur = s.source();
  }
  None
}

rustls::Error 要从 io::Error 里用 get_ref() 一层层剥出来:

fn peel_rustls(e: &reqwest::Error) -> Option<&rustls::Error> {
  let mut inner: Option<&(dyn Error + Send + Sync + 'static)> = find::<std::io::Error>(e)?.get_ref();
  while let Some(i) = inner {
    if let Some(r) = i.downcast_ref::<rustls::Error>() {
      return Some(r);
    }
    inner = i.downcast_ref::<std::io::Error>().and_then(|io| io.get_ref());
  }
  None
}
[refused] is_connect=true is_timeout=false io=Some(ConnectionRefused) rustls(find)=None rustls(peel)=None
[unknown CA] is_connect=true is_timeout=false io=Some(Other) rustls(find)=None rustls(peel)=Some(InvalidCertificate(UnknownIssuer))
[slow server] is_connect=false is_timeout=true io=None rustls(find)=None rustls(peel)=None
[closed early] is_connect=false is_timeout=false io=None rustls(find)=None rustls(peel)=None
  • 被拒绝:is_connect(),链底的 io::Error 是 ConnectionRefused。
  • 证书:也是 is_connect(),find::<rustls::Error> 是 None,peel_rustls 拿到 InvalidCertificate(UnknownIssuer)。这个 rustls 必须和 reqwest 用的是同一个 0.23.x,Cargo.lock 里只有一份时 downcast_ref 才认得。
  • 超时:is_timeout(),链里没有 io::Error。
  • 对端断开:四个判断全是假,链里是 hyper 的 IncompleteMessage,用 find::<hyper::Error> 再看 is_incomplete_message()。

链的形状会随配置变(设了超时,body 错误外面会多包一层 reqwest::Error,见 错误),所以只看 e.source() 一层再 downcast_ref 的代码不可靠,find 这种沿链找的写法不受影响。

怎么检查

  • 日志里出现 error sending request for url (...)、后面没有任何原因的行,就是只打了一层的地方。
  • 代码里搜 {e} / {} 和 to_string() 出现在 reqwest::Error 上、又被包进字符串错误的地方,比如 map_err(|e| format!("...{e}"))。
  • 单测里造一个连不上的端口,断言日志行里含 Connection refused。

本站的 LLM 客户端、论文采集和支付网关请求都是 map_err(|e| format!("...: {e}")),原因在这一步就丢了;这几处的写法见 本站的 reqwest。

小结

  • reqwest::Error 的 Display 只有一句话加 URL;拒绝连接、证书不认、超时、对端断开打出来一样。
  • 原因在 source() 链里:日志打印整条链(自己循环,或 anyhow 的 {:#} / {:?}),不要把错误 format! 成字符串再往上传。
  • 分支用 is_connect()、is_timeout() 和沿链 downcast_ref 的 find;rustls::Error 藏在 io::Error 里,source() 找不到,要用 get_ref() 剥。

💬 关联讨论

发起讨论
加载中...