Rust in Everything
基于 reqwest 0.13.5

4xx、5xx 也是 Ok

主线位置:响应主线的起点,send() 返回 Response 的那一刻。Response 里有什么、error_for_status 的代码、错误信息里的原因短语从哪里来,见 响应体 的「error_for_status」一节;Kind::Status 和 is_status() 见 错误。

flowchart TB
  send["send"] --> ok["Ok Response 429"]
  ok --> a["直接 json"]
  a --> dec["Decode 错误 原因不明"]
  ok --> b["转成错误"]
  b --> st["Status 错误"]
  st --> lost["body 和头已丢"]
  ok --> c["借用判断"]
  c --> keep["Response 还在"]
  keep --> txt["读出错误 body"]

实验 crate:exp-reqwest/pita-lab,src/bin/p06_status.rs。本机服务器的 /quota 回 429,body 是 {"error":"rate limited","retry_after":30};/ok 回 200 和 {"remaining":7}。客户端期望的类型是 struct Quota { remaining: u32 }。M2 Max、macOS 27、rustc 1.99.0,release。

现象

最常见的写法,? 只用在 send() 上:

async fn naive(c: &reqwest::Client, url: &str) -> reqwest::Result<Quota> {
  let r = c.get(url).send().await?; // 429 passes this `?`
  r.json::<Quota>().await
}
send():                 Ok(429)
naive:                  is_status=false is_decode=true
  error decoding response body for url (http://127.0.0.1:52076/quota)
      <- missing field `remaining` at line 1 column 41
naive into Value:       Ok(Object {"error": String("rate limited"), "retry_after": Number(30)})
  • send() 是 Ok(429)。
  • 错误最后在 json() 出现,种类是 Decode,说的是「缺字段 remaining」。限流这件事不在错误里,is_status() 是假,按状态码写的重试逻辑不会触发。
  • 解析成 serde_json::Value(或者字段全是 Option 的结构体)时,连错误都没有:错误页被当成了正常数据。

加上 error_for_status():

let r = c.get(url).send().await?.error_for_status()?; // body is gone here
error_for_status:       is_status=true status=Some(429)
  HTTP status client error (429 Too Many Requests) for url (http://127.0.0.1:52076/quota)

状态码对了,可服务器说的「rate limited」和 retry_after: 30 没了。

原因

send() 只关心有没有拿到响应头。 状态码是响应的一部分,不是传输层的失败;PendingRequest 拿到 hyper 的响应就包成 Response 返回,不看状态码。json() 也不看:它经 do_bytes 读完 body 就交给 serde_json::from_slice。

error_for_status 拿走 self。 Response::error_for_status:

    pub fn error_for_status(self) -> crate::Result<Self> {
        let status = self.status();
        let reason = self.extensions().get::<hyper::ext::ReasonPhrase>().cloned();
        if status.is_client_error() || status.is_server_error() {
            Err(crate::error::status_code(*self.url, status, reason))
        } else {
            Ok(self)
        }
    }

出错时它只把 URL、状态码和原因短语交给 status_code 造错误,self 在函数结束时被丢掉,body 还没读,响应头(比如 Retry-After)也一起没了。错误里只剩 status() 能拿到的那个数字。

修法

用借用的版本 error_for_status_ref 判断,Response 留在手里,失败时再读 body:

async fn keep_body(c: &reqwest::Client, url: &str) -> Result<Quota, String> {
  let r = c.get(url).send().await.map_err(|e| chain(&e))?;
  if let Err(e) = r.error_for_status_ref() {
    let body = r.text().await.unwrap_or_default();
    return Err(format!("{e}; body: {body}"));
  }
  r.json::<Quota>().await.map_err(|e| chain(&e))
}
error_for_status_ref:   Err
  HTTP status client error (429 Too Many Requests) for url (http://127.0.0.1:52076/quota); body: {"error":"rate limited","retry_after":30}
error_for_status_ref:   /ok -> Ok(Quota { remaining: 7 })

(chain 把错误的 source() 链逐层拼起来,见 错误。)实际代码里通常把它做成自己的错误类型:保留 StatusCode、需要的响应头和截断过的 body,重试判断看状态码,日志里写 body。body 可能很大,也可能带着不该进日志的数据,要自己定截断长度。

只关心成败、不需要错误内容时,error_for_status()? 仍然是最短的写法。关键是在解析 body 之前先判断状态码。

📝NOTE
冷知识:借用版是一年半以后才补上的

error_for_status 是提交 855e661「Add error_for_status.」加的(没有 PR 号),首次发布在 v0.7.0,提交说明是「This makes it it easy to turn error responses into error results.」它从一开始就拿 self。#457「add Response::error_for_status_ref to allow borrowing」(e49f5ee,首次发布在 v0.9.10)才加了借用版:diff 在同步和异步两个 Response 上各加了一个 error_for_status_ref(&self) -> Result<&Self>,同步版就是转调内部的异步版再 Ok(self)。在那之前,想在出错时读错误页,只能自己写 if res.status().is_client_error() || ...。今天两个方法的函数体几乎一样,差别只在拿 self 还是 &self,以及造错误时 URL 是移走还是 clone()。

怎么检查

  • 搜代码: 每一处 .send().await? 后面,在 .json() / .text() 之前,应该有 error_for_status、error_for_status_ref 或显式的 status() 判断。
  • 看错误种类: 日志里出现一串 error decoding response body,而上游正在限流或出错,先查调用点有没有判断状态码。
  • 写测试: 让测试服务器回 429 / 500 加一段 JSON 错误体,断言调用方拿到的是带状态码的错误、而且错误里有服务器的原文。

小结

  • send() 拿到响应头就是 Ok,4xx、5xx 不例外;json() 也不看状态码。
  • 不判断状态码,错误会在解码时以 Decode 出现,is_status() 为假;解析成 Value 时连错误都没有。
  • error_for_status 拿走 self,出错时 body 和响应头一起丢掉,只剩 URL、状态码和原因短语。
  • 要看服务器说了什么,用 error_for_status_ref 判断,失败时再 text()。

💬 关联讨论

发起讨论
加载中...