Rust in Everything
基于 reqwest 0.13.5

自定义重试把 POST 发了三遍

主线位置:请求主线上的重试层。它在 tower 栈里最靠近 hyper 的位置:FollowRedirect → CookieService → Retry → HyperService。分类器、每请求上限、预算这些机制见 重试。本页讲给它加一个「503 就重试」的分类器之后,最容易出的三个问题。

sequenceDiagram
  participant A as 调用方
  participant C as CookieService
  participant R as Retry 层
  participant S as 服务器
  A->>C: POST orders
  C->>R: 请求
  R->>S: 第 1 次
  S-->>R: 503 已写入
  R->>S: 第 2 次
  S-->>R: 503 已写入
  R->>S: 第 3 次
  S-->>R: 503 已写入
  R-->>C: 最后一个 503
  C-->>A: Ok 503

实验 crate:exp-reqwest/pitb-lab(src/bin/retrypost.rs)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,模板 feature(含 cookies),release 构建,客户端 .no_proxy()。服务器是同一个 crate 里的 hyper 服务器,每个请求打一行日志(序号、方法、路径、收到的 Cookie、回的状态码):POST /orders 先把订单计数加一,再回 503,模拟「后端写成功了,前面的网关超时」;POST /login 第一次回 503 并带 Set-Cookie: lb=node-2(负载均衡的粘滞 cookie),第二次回 200 并带 Set-Cookie: session=abc。

现象

上游偶尔回 503,于是照着文档写了一个分类器:

  let naive = retry::for_host("127.0.0.1").classify_fn(|rr| {
    if rr.status() == Some(StatusCode::SERVICE_UNAVAILABLE) {
      rr.retryable()
    } else {
      rr.success()
    }
  });
  let c = Client::builder().no_proxy().retry(naive).cookie_store(true).build().unwrap();

然后下了一个单:

== POST /orders with the 503 classifier
    server #1: POST /orders cookie=- -> 503
    server #2: POST /orders cookie=- -> 503
    server #3: POST /orders cookie=- -> 503
send() -> Ok(503)
orders written: 3
error_for_status() -> HTTP status server error (503 Service Unavailable) for url (http://127.0.0.1:54131/orders)
  • POST 被发了 3 次(原始 1 次加默认上限 2 次重试),中间没有任何间隔。每次后端都写成功了,订单表里多了 3 条。
  • 重试用完,send() 返回的是 Ok,里面是最后一个 503。let r = c.post(..).send().await?; 这一行顺利通过,失败要靠后面的 error_for_status() 才看得出来。

再登录一次,紧接着访问一个需要 cookie 的页面:

== POST /login, then GET /me
    server #4: POST /login cookie=- -> 503
    server #5: POST /login cookie=- -> 200
login -> 200 OK
    server #6: GET /me cookie=session=abc -> 200
  • 第一个响应上的 Set-Cookie: lb=node-2 不见了。 下一个请求只带了 session=abc。如果负载均衡靠这个 cookie 把后续请求钉在同一个节点上,钉住就失效了。

原因

分类器只看你让它看的东西。 ReqRep 提供 method()、uri()、status()、error(),reqwest 不替你按方法过滤。默认分类器之所以对 POST 也安全,是因为它只重试 HTTP/2 的 REFUSED_STREAM 和 GOAWAY,这两种情况下服务器保证没有处理过这个流;状态码类的重试是你自己加的,503 不代表服务器没处理过。for_host 经 Builder::scoped 建出的策略,每个请求的上限是 max_retries_per_request: 2, // on top of the original,重试之间没有退避。

最后一个结果原样交回。 tower 的 Retry 在策略说「不再重试」时,把最后一次的结果交给上一层;503 是一个 Ok(Response),于是 send() 也是 Ok。状态码从来不会自动变成错误,这一点和没开重试时一样(见 error_for_status)。

cookie 层在重试层外面。 build() 里栈是这样拼的(Retry::new):

        let svc = tower::retry::Retry::new(retry_policy.clone(), hyper_service);

        #[cfg(feature = "cookies")]
        let svc = CookieService::new(svc, config.cookie_store.clone());
        let hyper = FollowRedirect::with_policy(svc, redirect_policy.clone());

CookieService 只看见 Retry 交回来的最终响应,被重试掉的那些响应在 Retry 内部就被丢了,上面的 Set-Cookie 根本没到 cookie store。这个层次关系的实测见 cookie。

修法

分类器先看方法。 只重试幂等方法(GET、HEAD、PUT、DELETE、OPTIONS、TRACE),http::Method::is_idempotent() 正好是这个集合:

  let fixed = retry::for_host("127.0.0.1").classify_fn(|rr| {
    if rr.method().is_idempotent() && rr.status() == Some(StatusCode::SERVICE_UNAVAILABLE) {
      rr.retryable()
    } else {
      rr.success()
    }
  });
== fixed classifier: only idempotent methods
    server #7: POST /orders cookie=- -> 503
POST -> 503 Service Unavailable, orders written: 1
    server #8: GET /orders cookie=- -> 503
    server #9: GET /orders cookie=- -> 503
    server #10: GET /orders cookie=- -> 503
GET  -> 503 Service Unavailable
    server #11: POST /login cookie=- -> 503
login -> 503 Service Unavailable
    server #12: GET /me cookie=lb=node-2 -> 200

POST 只发一次,GET 照常重试。最后三行是同一个修好的客户端(也开了 cookie_store(true))重新登录:POST 不再被重试,503 交回调用方,它的 Set-Cookie: lb=node-2 经过 CookieService 存了进去,下一个请求带上了它。POST 真要重试,前提是服务端支持幂等键(同一个键的请求只处理一次);分类器拿不到请求头,没法按「带没带幂等键」区分,这种情况最好在业务代码里自己重试,而不是交给 Client。

send() 之后接 error_for_status()。 重试用完的 503 才会变成 Err,上面第一段输出的最后一行就是它。

需要每个响应上的 cookie,就别在 Client 上重试那个请求。 上面的登录接口就是这样:不重试,503 交回调用方,cookie 存进去了,再由业务代码决定是否重发。不按方法区分的接口,可以在分类器里用 rr.uri() 按路径排除。

怎么检查

  • 服务端看同一个请求在几毫秒内连着来了 3 次(没有退避),就是 Client 层的重试。
  • 代码里搜 classify_fn / classify,看分类器里有没有 method()。
  • 搜 send().await? 后面没有 error_for_status 的地方:开了状态码重试之后,它们拿到的 Ok 可能是重试用完的失败。

小结

  • 自己写的状态码分类器不看方法,POST 也会被重发;默认上限 2 次重试、没有退避,一个 POST 最多到达服务器 3 次。
  • 重试用完返回的是最后一个响应,Ok(503),要 error_for_status() 才变成错误。
  • CookieService 在 Retry 外面,被重试掉的响应上的 Set-Cookie 不进 cookie store。
  • 修法:分类器里先判断 rr.method().is_idempotent();send() 后接 error_for_status();需要每个响应副作用的请求不交给 Client 重试。

💬 关联讨论

发起讨论
加载中...