Rust in Everything
基于 reqwest 0.13.5

错误

主线位置:三条主线共同的出口。请求主线上的构建错误、连接主线上的 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
BuilderRequestBuilder 攒下的错误、ClientBuilder::build、scheme 不对、Url 转不成 Uribuilder error
RequestHyperService 包住 hyper-util 的一切错误;PendingRequest 的两个超时error sending request
Redirect重定向策略返回错误,比如次数超限error following redirect
Status(StatusCode, Option<ReasonPhrase>)error_for_status / error_for_status_refHTTP status client error (...) 或 server error
Body请求 Body 的流出错;响应体的两个超时包装request or response body error
Decodebytes、text、json、chunk、bytes_stream;重定向后的地址解析失败error decoding response body
UpgradeResponse::upgradeerror 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_* 为真:种类一个,链上的若干个。

❗IMPORTANT
动脑筋: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 打出来,但它是给人看的嵌套结构,不适合匹配。

📝NOTE
冷知识:0.12.0 的 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。

💬 关联讨论

发起讨论
加载中...