错误
主线位置:三条主线共同的出口。请求主线上的构建错误、连接主线上的 DNS / TCP / TLS 失败、响应主线上的读 body 失败和状态码,最后都变成同一个
reqwest::Error;它只记一个粗分类和一个 URL,具体原因原样挂在source()链上。
flowchart TB rb["RequestBuilder"] --> b["Builder"] hs["HyperService"] --> rq["Request"] pr["PendingRequest 超时"] --> rq fr["重定向策略"] --> rd["Redirect"] efs["error_for_status"] --> st["Status"] wrap["超时包装 请求体"] --> bd["Body"] rm["读 body 的方法"] --> dc["Decode"] up["upgrade"] --> ug["Upgrade"]
实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,服务端是本机的 testsrv 和 exp-reqwest/resp-lab 里的固定响应服务器,客户端一律 .no_proxy()。程序是 resp-lab/src/bin/errors.rs,连跑 3 次,除端口号外输出相同。
一个指针
Error 外面只有一个 Box:
pub struct Error {
inner: Box<Inner>,
}
pub(crate) type BoxError = Box<dyn StdError + Send + Sync>;
struct Inner {
kind: Kind,
source: Option<BoxError>,
url: Option<Url>,
}
error.rs 的测试 mem_size_of 断言 size_of::<Error>() == size_of::<usize>()。Result<Response, Error> 因此很小,出错的路径多一次分配,成功的路径不受影响。kind 是粗分类,source 是原因(任何错误类型,装箱后类型被擦掉),url 是出错的那个请求的 URL。
七种 Kind
Kind 是私有的,外面只能用 is_* 判断。每种由一个同名的构造函数生成:
| Kind | 谁构造 | Display |
|---|---|---|
Builder | RequestBuilder 攒下的错误、ClientBuilder::build、scheme 不对、Url 转不成 Uri | builder error |
Request | HyperService 包住 hyper-util 的一切错误;PendingRequest 的两个超时 | error sending request |
Redirect | 重定向策略返回错误,比如次数超限 | error following redirect |
Status(StatusCode, Option<ReasonPhrase>) | error_for_status / error_for_status_ref | HTTP status client error (...) 或 server error |
Body | 请求 Body 的流出错;响应体的两个超时包装 | request or response body error |
Decode | bytes、text、json、chunk、bytes_stream;重定向后的地址解析失败 | error decoding response body |
Upgrade | Response::upgrade | error upgrading connection |
连接、DNS、TLS 都没有自己的种类:它们发生在 hyper-util 里,被 HyperService 一律包成 Request。超时也没有:它是 source() 链上的一个私有类型 TimedOut,可以出现在 Request、Body、Decode 下面。
is_*:两种判断法
一类只看 kind:is_builder、is_request、is_redirect、is_status、is_body、is_decode、is_upgrade,都是一行 matches!。另一类沿 source() 链往下找,is_timeout 是最复杂的:
pub fn is_timeout(&self) -> bool {
let mut source = self.source();
while let Some(err) = source {
if err.is::<TimedOut>() {
return true;
}
if let Some(err) = err.downcast_ref::<Error>() {
if err.is_timeout() {
return true;
}
}
// ... cfg(not(wasm))
if let Some(hyper_err) = err.downcast_ref::<hyper::Error>() {
if hyper_err.is_timeout() {
return true;
}
}
if let Some(io) = err.downcast_ref::<io::Error>() {
if io.kind() == io::ErrorKind::TimedOut {
return true;
}
}
source = err.source();
}
false
}
链上任何一层是 reqwest 的 TimedOut、是 is_timeout() 的 hyper 错误、或是 TimedOut 种类的 io::Error,就算超时。自己用 connector_layer 套的 tower TimeoutLayer,其 Elapsed 被 cast_to_internal_error 换成了 TimedOut,所以也认得出(见 连接器)。
is_connect 在链上找 hyper-util 的 legacy::Error 并问它的 is_connect(),所以 DNS 失败、TCP 被拒、TLS 握手失败、connect_timeout 触发都算;is_dns(0.13.5 新加,#3057)找 reqwest 的私有类型 DnsError:DynResolver 把 resolver 返回的任何错误都包成它,自定义 resolver 也一样。
一个错误可以同时有几个 is_* 为真:种类一个,链上的若干个。
timeout(300ms),服务器发完响应头和第一块 body 后停住不动。bytes() 返回的错误,is_timeout、is_body、is_decode、is_request 哪几个为真?平台:M2 Max、macOS 27,reqwest 0.13.5,testsrv 的 ?chunks=3&size=16&body_ms=1000。
Display 只打一层
Display 只写种类的固定短句,再加 for url (...),不写 source:
match self.inner.kind {
Kind::Builder => f.write_str("builder error")?,
Kind::Request => f.write_str("error sending request")?,
// ...
};
if let Some(url) = &self.inner.url {
write!(f, " for url ({url})")?;
}
所以 println!("{e}") 或 tracing::error!(%e) 只能看到 error sending request for url (...),连被拒绝、证书不对、超时都长一个样。原因要沿着 source() 一层层取。hyper-util 的 legacy::Error 和 hyper 的 Error 也是同样的约定(见 hyper 卷 hyper::Error 的「Display 只打一层」)。Debug 会把 source 打出来,但它是给人看的嵌套结构,不适合匹配。
Display 曾经把 source 接在后面#2199「fix: Display for Error shouldn't include source」(3d78fcb,首次发布在 v0.12.1,CHANGELOG 写作「Fix Display for Error to not include its source error.」)。diff 删掉了 Display 末尾的四行:
- if let Some(e) = &self.inner.source {
- write!(f, ": {e}")?;
- }
改之前,{e} 输出 error sending request for url (...): <source>;而 source() 又返回同一个原因,用 anyhow 的 {:#} 或任何「逐层打印 source 链」的日志工具,原因会出现两遍。提交说明是空的;改成现在这样以后,Display 和 source() 各管一层,和 hyper、hyper-util 的约定一致。代价是只打 {e} 的老代码从此看不到原因。
打印整条链的一个写法(实验用的就是它,另外标出每层的类型):
pub fn chain(e: &(dyn std::error::Error + 'static)) -> String {
let mut out = format!("{e}");
let mut cur = e.source();
while let Some(s) = cur {
out.push_str(&format!("\n <- {s}"));
cur = s.source();
}
out
}
每种失败的链
逐个触发:端口没人听、证书由不认识的 CA 签发(testsrv 的 TLS 模式,客户端不加它的 CA)、服务器读完请求直接关连接(?abort=1)、自定义 resolver 返回错误、11 次重定向(默认上限 10)、非法头值。方括号里是这一层的类型,认不出的打印 Debug:
[refused] is_[request,connect]
{} : error sending request for url (http://127.0.0.1:63225/)
chain: error sending request for url (http://127.0.0.1:63225/)
<- client error (Connect) [hyper_util::client::legacy::Error]
<- tcp connect error [Debug: ConnectError("tcp connect error", 127.0.0.1:63225, Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })]
<- Connection refused (os error 61) [std::io::Error]
io::Error kind = ConnectionRefused, raw_os_error = Some(61)
{:?} : reqwest::Error { kind: Request, url: "http://127.0.0.1:63225/", source: hyper_util::client::legacy::Error(Connect, ConnectError("tcp connect error", 127.0.0.1:63225, Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })) }
[unknown CA] is_[request,connect]
{} : error sending request for url (https://localhost:63227/)
chain: error sending request for url (https://localhost:63227/)
<- client error (Connect) [hyper_util::client::legacy::Error]
<- invalid peer certificate: UnknownIssuer [std::io::Error]
find::<rustls::Error> = None
peeled: rustls::Error = InvalidCertificate(UnknownIssuer)
io::Error kind = Other, get_ref() as rustls::Error = None
io::Error Debug = Custom { kind: Other, error: Custom { kind: InvalidData, error: InvalidCertificate(UnknownIssuer) } }
[closed early] is_[request]
{} : error sending request for url (http://127.0.0.1:63223/?abort=1)
chain: error sending request for url (http://127.0.0.1:63223/?abort=1)
<- client error (SendRequest) [hyper_util::client::legacy::Error]
<- connection closed before message completed [hyper::Error]
hyper::Error is_incomplete_message = true
[dns] is_[request,connect,dns]
{} : error sending request for url (http://api.example.test/)
chain: error sending request for url (http://api.example.test/)
<- client error (Connect) [hyper_util::client::legacy::Error]
<- dns error [Debug: ConnectError("dns error", DnsError { inner: "no record for api.example.test" })]
<- error resolving DNS [Debug: DnsError { inner: "no record for api.example.test" }]
<- no record for api.example.test [Debug: "no record for api.example.test"]
[redirect] is_[redirect]
{} : error following redirect for url (http://127.0.0.1:63223/redirect/1)
chain: error following redirect for url (http://127.0.0.1:63223/redirect/1)
<- too many redirects [Debug: TooManyRedirects]
...
[builder] is_[builder]
{} : builder error
chain: builder error
<- failed to parse header value [Debug: http::Error(InvalidHeaderValue)]
取具体原因用一个沿链 downcast_ref 的小函数:
fn find<T: std::error::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
}
被拒绝:链的底是
std::io::Error,find::<std::io::Error>拿到它,kind()是ConnectionRefused,raw_os_error()是 61(macOS 的ECONNREFUSED)。中间那层tcp connect error是 hyper-util 的私有类型ConnectError,只能看不能取。证书不认识:
find::<rustls::Error>找不到。hyper-rustls 用io::Error::other把 tokio-rustls 返回的io::Error(InvalidData,里面才是rustls::Error)又包了一层,而io::Error::source()返回的是内层错误的source(),跳过了内层错误本身,链在这里断了。要拿到rustls::Error,得对io::Error用get_ref()一层层剥:let mut inner: Option<&(dyn std::error::Error + Send + Sync + 'static)> = io.get_ref(); while let Some(i) = inner { if let Some(r) = i.downcast_ref::<rustls::Error>() { println!(" peeled: rustls::Error = {r:?}"); break; } inner = i.downcast_ref::<std::io::Error>().and_then(|io| io.get_ref()); }剥两层得到
InvalidCertificate(UnknownIssuer)。这个类型要和 reqwest 用的是同一个 rustls(同一个0.23.x,lock 里只有一份),downcast_ref才认。这个错误的含义见 rustls 卷的 unknown issuer。服务器读完请求就关:
SendRequest下面是 hyper 的IncompleteMessage。注意它不是is_connect():连接建好了,是发请求以后坏的。默认重试策略会不会重发这类请求见 重试。DNS:
is_connect()和is_dns()都为真;自定义 resolver 返回的字符串错误在链的最底下。重定向:URL 是最后一个返回重定向的地址,不是起点。策略本身见 重定向。
构建错误:没有 URL,也没有头名(请求构建 讲过)。
同一个失败,链的形状会变
服务器声明 Content-Length: 100,只发 10 字节就关连接。同一个服务器,客户端没设超时和设了 timeout(5s) 各读一次:
[short body, no timeout] is_[decode]
{} : error decoding response body for url (http://127.0.0.1:63236/)
chain: error decoding response body for url (http://127.0.0.1:63236/)
<- error reading a body from connection [hyper::Error]
<- end of file before message length reached [std::io::Error]
[short body, timeout(5s)] is_[decode]
{} : error decoding response body for url (http://127.0.0.1:63236/)
chain: error decoding response body for url (http://127.0.0.1:63236/)
<- request or response body error [reqwest::Error]
<- error reading a body from connection [hyper::Error]
<- end of file before message length reached [std::io::Error]
设了超时,链上多出一层 Kind::Body 的 reqwest 错误。原因在 body::response:没有超时时 body 只装箱,hyper 的错误原样上来;有超时时要经过 TotalTimeoutBody 或 ReadTimeoutBody,它们把内层错误一律 map_err(crate::error::body)。写成 e.source()?.downcast_ref::<hyper::Error>() 这种只看一层的代码,在加上超时配置的那天就悄悄失效了。按类型沿链找(上面的 find)不受影响。
URL 与 without_url
Error 的文档开头就提醒:错误里可能带着完整 URL,URL 里有敏感信息(比如查询参数里的 API key)时要去掉。实验:
[url] error sending request for url (http://127.0.0.1:63225/v1?api_key=SECRET)
[url_mut] error sending request for url (http://127.0.0.1:63225/v1)
[without_url] error sending request
第一行的请求 URL 其实是 http://user:s3cret@127.0.0.1:.../v1?api_key=SECRET:RequestBuilder 在构造时用 extract_authority 把用户名密码挪进 Authorization 头,并从 URL 里删掉,所以错误里没有它们;查询参数还在。处理办法有两种:
url_mut拿到&mut Url改掉敏感部分,保留主机和路径方便排查(第二行,set_query(None));without_url整个去掉(第三行)。
两者都只改这个 Error 自己的 url 字段;链上如果还有别的 reqwest 错误(比如 Decode 下面那层 Body),它们本来就不带 URL。Debug 输出里同样有 url 字段,打日志用 {:?} 时一样要处理。
json 解码失败,和留住原文
json() 的错误是 Decode,下面直接是 serde_json::Error,URL 从 0.13.4 起才有(#3026,「fix: do not lose the url in error when decoding json」:之前 json 先调 bytes(),再对 serde_json 的错误 map_err(crate::error::decode),bytes() 自己的错误也不带 URL;这个提交加了 do_bytes,把 URL 一起传出来)。三种失败:字段类型不对、body 被截断、服务器回了一页 HTML 的 502:
[json wrong-type] is_[decode]
{} : error decoding response body for url (http://127.0.0.1:63224/json?kind=wrong-type)
chain: error decoding response body for url (http://127.0.0.1:63224/json?kind=wrong-type)
<- invalid type: string "seven", expected u32 at line 1 column 13 [serde_json::Error]
serde_json: classify=Data line=1 column=13
[json truncated] is_[decode]
{} : error decoding response body for url (http://127.0.0.1:63224/json?kind=truncated)
chain: error decoding response body for url (http://127.0.0.1:63224/json?kind=truncated)
<- EOF while parsing a string at line 1 column 11 [serde_json::Error]
serde_json: classify=Eof line=1 column=11
[json html502] is_[decode]
{} : error decoding response body for url (http://127.0.0.1:63224/json?kind=html502)
chain: error decoding response body for url (http://127.0.0.1:63224/json?kind=html502)
<- expected value at line 1 column 1 [serde_json::Error]
serde_json: classify=Syntax line=1 column=1
find::<serde_json::Error> 拿到的错误有 classify()(Data、Eof、Syntax、Io 四类,见 serde 卷的 错误)和行列号。但最常见的那种失败,第三行,单看错误永远猜不出原因:expected value at line 1 column 1 只说明第一个字节不是 JSON,状态码是 502 这件事不在错误里,json() 也不检查状态码和 Content-Type,body 已经随 self 消费掉了。
要排查,就自己读 body,再自己解析:
async fn json_with_body<T: serde::de::DeserializeOwned>(r: reqwest::Response) -> Result<T, String> {
let status = r.status();
let url = r.url().clone();
let body = r.bytes().await.map_err(|e| e.to_string())?;
serde_json::from_slice(&body).map_err(|e| {
let head = String::from_utf8_lossy(&body[..body.len().min(60)]);
format!("{status} from {url}: {e}; body starts with {head:?}")
})
}
[json_with_body] 502 Bad Gateway from http://127.0.0.1:62848/json?kind=html502: expected value at line 1 column 1; body starts with "<html><body><h1>502 Bad Gateway</h1><p>upstream timed out</p"
这和 json() 内部做的事一样(do_bytes 加 serde_json::from_slice),只是 body 留在自己手里。也可以先 error_for_status_ref() 判断状态码,失败时 text() 读出错误页;成功时再解析。截断 body 的长度要自己定:错误信息会进日志,body 里可能有不该记下的数据。
动脑筋答案
超时一页的实验里,这一行就是答案:
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]
is_timeout 和 is_decode 为真,is_body 和 is_request 为假。 TotalTimeoutBody 产生的是 body(TimedOut),种类 Body;但它在链的第二层。bytes() 经 do_bytes 把它包进 decode(...),最外层的种类是 Decode,而 is_body() 只看最外层。is_timeout() 沿链往下找到了 TimedOut。用 reqwest 的五个读方法时,响应体上的任何失败最外层都是 Decode。按「body 出错」写判断时,要用 is_decode(),或者沿链找。
小结
Error是一个Box:种类、可选的原因、可选的 URL。Kind七种,私有,只能用is_*判断。- 连接、DNS、TLS、超时都不是种类:前三者被包成
Request,靠is_connect()/is_dns()沿链找;超时是链上的私有类型TimedOut,靠is_timeout()找,可以挂在Request、Body、Decode下面。 Display只打种类和 URL(0.12.1 起),原因要沿source()取;按类型downcast_ref找,不要按层数取。- TLS 的
rustls::Error藏在两层io::Error里,source()链走不到,要用get_ref()剥。 - 同一个 body 读失败,设没设超时,链上会差一层
Kind::Body。 - 响应 body 上的失败用读方法拿到的都是
Decode,is_body()为假。 RequestBuilder把 URL 里的用户名密码挪进头里,查询参数还留在错误的 URL 里;用url_mut改或without_url去掉,Debug输出同样带 URL。json()失败时 body 已经没了,也不看状态码和Content-Type;要诊断就先bytes(),再自己serde_json::from_slice。