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 之前先判断状态码。
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()。