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()剥。