重定向
主线位置:请求主线上的第二层。
execute_request拼好的请求先过解压层,再进FollowRedirect;它把请求交给里面的 cookie 层和重试层,拿到 3xx 响应后问 reqwest 的策略要不要跟,要跟就改写请求、再发一次。整个循环都在一次send().await里完成,调用方只看到最后一个响应。
sequenceDiagram participant U as send participant FR as FollowRedirect participant P as TowerRedirectPolicy participant In as CookieService 以内 U->>FR: 请求 A FR->>P: on_request FR->>In: 请求 A In-->>FR: 302 Location B FR->>FR: 按状态码改方法和 body FR->>P: redirect 问要不要跟 P-->>FR: Follow FR->>P: on_request 去敏感头 加 Referer FR->>In: 请求 B In-->>FR: 200 FR-->>U: 200 和 RequestUri B
实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,卷里模板的 feature,release 构建。源站是本机自己写的 hyper 服务器(exp-reqwest/policy-lab):/to/NNN?loc=URL 回状态码 NNN 和 Location: URL,/hop/N 回 302 到 /hop/N-1,每收到一个请求打一行日志,列出方法、路径、几个关心的请求头和 body 字节数。同一端口既收明文也收 TLS(看第一个字节是不是 0x16),用来测「只换 scheme」的跳转。客户端一律 .no_proxy()。
循环归 tower-http,判断归 reqwest
ClientBuilder::build 里,重定向层是这样装上的:
let redirect_policy = {
let mut p = TowerRedirectPolicy::new(config.redirect_policy);
p.with_referer(config.referer)
.with_https_only(config.https_only);
p
};
let retry_policy = config.retry_policy.into_policy();
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());
FollowRedirect 来自 tower-http 0.6.11(Service 和 Layer 本身见 tower 卷 Layer 与 Stack)。它的 call 把策略克隆一份放进这次请求的 future,记下方法、URI、版本、请求头,试着克隆一份 body,然后发出去。响应回来后,ResponseFuture::poll 做四件事:按状态码决定下一跳的方法和 body;取 Location 并按当前 URI 解析成绝对地址;调策略的 redirect;策略说 Follow 就新建一个请求,先调策略的 on_request,再交给内层服务。每个响应都被插进一个 RequestUri 扩展,PendingRequest::poll 从这里取出最终 URL,Response::url() 返回的就是它。
策略是 tower-http 的 Policy trait,三个方法:redirect(跟不跟)、on_request(每次发请求前改请求)、clone_body(怎么复制 body)。reqwest 不用 tower-http 自带的 Standard(Limited 加 FilterCredentials),而是自己实现了一个 TowerRedirectPolicy:
#[derive(Clone)]
pub(crate) struct TowerRedirectPolicy {
policy: Arc<Policy>,
referer: bool,
urls: Vec<Url>,
https_only: bool,
}
tower-http Standard | reqwest TowerRedirectPolicy | |
|---|---|---|
| 次数上限 | Limited 默认 20,到了返回 Stop(把 3xx 交给调用方) | redirect::Policy 默认 10,到了返回错误 |
| 历史 | 只有上一跳 previous() | urls 记下整条链,交给用户策略的 Attempt::previous() |
| scheme | 不管 | 只许 http / https;https_only 时只许 https |
| 跨域去头 | FilterCredentials | remove_sensitive_headers,名单多两个 |
| Referer | 不加 | 默认加 |
| body 克隆 | 默认不克隆 | Body::try_clone |
urls 在 TowerRedirectPolicy 里,而 FollowRedirect::call 每个请求克隆一份策略,所以历史是按请求记的,克隆时 urls 还是空的(Client 里存的那份从来不往里写)。policy 是 Arc,用户的闭包不会被复制。
redirect 的实现:
fn redirect(&mut self, attempt: &TowerAttempt<'_>) -> Result<TowerAction, crate::Error> {
let previous_url =
Url::parse(&attempt.previous().to_string()).expect("Previous URL must be valid");
let next_url = match Url::parse(&attempt.location().to_string()) {
Ok(url) => url,
Err(e) => return Err(crate::error::builder(e)),
};
self.urls.push(previous_url.clone());
match self.policy.check(attempt.status(), &next_url, &self.urls) {
ActionKind::Follow => {
if next_url.scheme() != "http" && next_url.scheme() != "https" {
return Err(crate::error::url_bad_scheme(next_url));
}
if self.https_only && next_url.scheme() != "https" {
return Err(crate::error::redirect(
crate::error::url_bad_scheme(next_url),
next_url,
));
}
Ok(TowerAction::Follow)
}
ActionKind::Stop => Ok(TowerAction::Stop),
ActionKind::Error(e) => Err(crate::error::redirect(e, previous_url)),
}
}
先把「发出这个 3xx 的 URL」压进 urls,再问用户策略。所以用户策略看到的 previous() 已经包含当前这一跳的来源,第一次被问时长度就是 1。
redirect::Policy:三种策略
redirect::Policy 包着一个私有枚举 PolicyKind,三个构造函数:limited(max)、none()、custom(f)。Default 是 limited(10)。判断都在 Policy::redirect:
pub fn redirect(&self, attempt: Attempt) -> Action {
match self.inner {
PolicyKind::Custom(ref custom) => custom(attempt),
PolicyKind::Limit(max) => {
// The first URL in the previous is the initial URL and not a redirection. It needs to be excluded.
if attempt.previous.len() > max {
attempt.error(TooManyRedirects)
} else {
attempt.follow()
}
}
PolicyKind::None => attempt.stop(),
}
}
previous 里第一个是最初的 URL,不算一次重定向,所以比较用 >:limited(10) 跟 10 次,第 11 个 3xx 报错。none() 返回 stop,3xx 响应原样作为 Ok 交给调用方。custom 不做任何次数检查,注释特意提醒自定义策略要自己防循环;想在默认行为上加点东西,可以在闭包里调 Policy::default().redirect(attempt)。设置入口是 ClientBuilder::redirect,只能按 Client 设,RequestBuilder 上没有按请求改的办法。
实测(redirect limit,/hop/N 是 N 跳的链):
## default policy, 10 hops
Ok 200 OK final=http://127.0.0.1:62215/hop/0 location=None
[A http] GET /hop/10 body=0
[A http] GET /hop/9 referer="http://127.0.0.1:62215/hop/10" body=0
...
[A http] GET /hop/0 referer="http://127.0.0.1:62215/hop/1" body=0
## default policy, 11 hops
Err display: error following redirect for url (http://127.0.0.1:62215/hop/1)
Err debug: reqwest::Error { kind: Redirect, url: "http://127.0.0.1:62215/hop/1", source: TooManyRedirects }
is_redirect=true is_builder=false url=Some("http://127.0.0.1:62215/hop/1") chain: -> too many redirects
[A http] GET /hop/11 body=0
...
[A http] GET /hop/1 referer="http://127.0.0.1:62215/hop/2" body=0
## limited(0), 1 hop
Err display: error following redirect for url (http://127.0.0.1:62215/hop/1)
Err debug: reqwest::Error { kind: Redirect, url: "http://127.0.0.1:62215/hop/1", source: TooManyRedirects }
is_redirect=true is_builder=false url=Some("http://127.0.0.1:62215/hop/1") chain: -> too many redirects
[A http] GET /hop/1 body=0
## none(), 1 hop
Ok 302 Found final=http://127.0.0.1:62215/hop/1 location=Some("/hop/0")
[A http] GET /hop/1 body=0
## custom error at 2nd previous
policy sees status=302 Found next=http://127.0.0.1:62215/hop/2 previous=["http://127.0.0.1:62215/hop/3"]
policy sees status=302 Found next=http://127.0.0.1:62215/hop/1 previous=["http://127.0.0.1:62215/hop/3", "http://127.0.0.1:62215/hop/2"]
Err display: error following redirect for url (http://127.0.0.1:62215/hop/2)
Err debug: reqwest::Error { kind: Redirect, url: "http://127.0.0.1:62215/hop/2", source: "custom says no" }
is_redirect=true is_builder=false url=Some("http://127.0.0.1:62215/hop/2") chain: -> custom says no
...
- 10 跳的链走完,服务端收到 11 个请求;11 跳的链在第 11 个请求的 302 回来后报错,
/hop/0没有被请求。 - 错误的
Display只有「error following redirect for url (...)」,原因too many redirects在source()里;直接{e}打日志看不到为什么。错误里的 URL 是发出最后一个 3xx 的 URL(/hop/1),不是Location指向的地址。错误种类和source()链见错误。 none()拿到的 302 带着原始Location(这里是相对路径/hop/0),final是请求本身的 URL。- 自定义策略里返回的错误原样成为
source(),is_redirect()为真。
limited(10) 曾经只跟 9 次#2664(10ba9f3,「fix(redirect): make the number of redirects of policy matches its maximum limit.」,首次发布在 v0.12.16),提交说明只有「Fixes #2659」。改之前条件是 attempt.previous.len() >= max:previous 里的第一个元素是最初的 URL,并不是一次重定向,用 >= 就少算了一次,limited(10) 实际只跟 9 次,limited(1) 一次也不跟。diff 只改了两处:条件换成 >,加上上面代码里那行注释;单元测试 test_redirect_policy_limit 里构造的 previous 从 (0..9) 改成 (0..=9),即 10 个 URL 时仍然 Follow。上面「10 hops」那组输出就是修好之后的行为。
方法和 body:按状态码分三类
下一跳用什么方法、带不带 body,reqwest 自己不管,全在 tower-http 的 ResponseFuture::poll 里:
match res.status() {
StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => {
// User agents MAY change the request method from POST to GET
// (RFC 7231 section 6.4.2. and 6.4.3.).
if *this.method == Method::POST {
*this.method = Method::GET;
*this.body = BodyRepr::Empty;
drop_payload_headers(this.headers);
}
}
StatusCode::SEE_OTHER => {
// A user agent can perform a GET or HEAD request (RFC 7231 section 6.4.4.).
if *this.method != Method::HEAD {
*this.method = Method::GET;
}
*this.body = BodyRepr::Empty;
drop_payload_headers(this.headers);
}
StatusCode::TEMPORARY_REDIRECT | StatusCode::PERMANENT_REDIRECT => {}
_ => return Poll::Ready(Ok(res)),
};
let body = if let Some(body) = this.body.take() {
body
} else {
return Poll::Ready(Ok(res));
};
drop_payload_headers 删掉 Content-Type、Content-Length、Content-Encoding、Transfer-Encoding。this.body 是 BodyRepr:Some(body) 是事先克隆好的一份,Empty 表示下一跳用空 body,None 表示没有可用的 body。克隆发生在 call 时(以及每次跟随时,给再下一跳备一份),走 clone_body:size_hint 精确为 0 时直接用默认值,否则调策略的 clone_body。reqwest 的实现只有一行,clone_body 调 Body::try_clone:
pub(crate) fn try_clone(&self) -> Option<Body> {
match self.inner {
Inner::Reusable(ref chunk) => Some(Body::reusable(chunk.clone())),
Inner::Streaming { .. } => None,
}
}
内存里的 Bytes 能克隆,流式 body(wrap_stream、multipart)不能(两种 body 见请求体)。克隆不了时 BodyRepr 是 None;如果状态码又要求原样重发(307、308,或者 301/302 遇到非 POST),take() 返回 None,3xx 响应直接作为 Ok 返回,连策略的 redirect 都不会被调用。
实测(redirect methods,body 都是 5 字节,第二行是跟随后的请求):
| 请求 | 状态码 | 第二个请求 | 结果 |
|---|---|---|---|
POST,Bytes | 301 / 302 / 303 | GET,无 content-type,body 0 | 200 |
PUT,Bytes | 301 / 302 | PUT,content-type 和 5 字节 body 照发 | 200 |
PUT,Bytes | 303 | GET,body 0 | 200 |
POST / PUT,Bytes | 307 / 308 | 方法不变,5 字节 body 照发 | 200 |
| HEAD | 303 | HEAD | 200 |
| POST,流 | 302 / 303 | GET,body 0 | 200 |
| PUT,流 | 302 | 没有 | Ok 302 Found |
| PUT,流 | 303 | GET,body 0 | 200 |
| POST / PUT,流 | 307 | 没有 | 见动脑筋 |
原始日志的一段:
## PUT 302 Bytes body
Ok 200 OK final=http://127.0.0.1:62318/ok location=None
[A http] PUT /to/302?loc=/ok content-type="text/plain" content-length="5" body=5
[A http] PUT /ok referer="http://127.0.0.1:62318/to/302?loc=/ok" content-type="text/plain" content-length="5" body=5
## PUT 302 stream body
Ok 302 Found final=http://127.0.0.1:62318/to/302?loc=/ok location=Some("/ok")
[A http] PUT /to/302?loc=/ok transfer-encoding="chunked" body=5
301/302 只把 POST 改成 GET,PUT、DELETE、PATCH 都原样带 body 重发。流式 body 在需要重发时不报错,调用方拿到的是一个成功的 3xx,要自己看 status()。
POST 一个流式 body(Body::wrap_stream,内容 5 字节)到一个回 307 Temporary Redirect 的地址,默认 Client。send().await 返回什么?服务端一共收到几个请求?平台:M2 Max、macOS 27,reqwest 0.13.5,明文 HTTP/1.1。跨域去掉敏感头
每次跟随前,tower-http 调 on_request:
fn on_request(&mut self, req: &mut http::Request<async_impl::body::Body>) {
if let Ok(next_url) = Url::parse(&req.uri().to_string()) {
remove_sensitive_headers(req.headers_mut(), &next_url, &self.urls);
if self.referer {
if let Some(previous_url) = self.urls.last() {
if let Some(v) = make_referer(&next_url, previous_url) {
req.headers_mut().insert(REFERER, v);
}
}
}
};
}
第一个请求也会经过 on_request,那时 urls 为空,两件事都不做。remove_sensitive_headers:
pub(crate) fn remove_sensitive_headers(headers: &mut HeaderMap, next: &Url, previous: &[Url]) {
if let Some(previous) = previous.last() {
let cross_host = next.host_str() != previous.host_str()
|| next.port_or_known_default() != previous.port_or_known_default()
|| next.scheme() != previous.scheme();
if cross_host {
headers.remove(AUTHORIZATION);
headers.remove(COOKIE);
headers.remove("cookie2");
headers.remove(PROXY_AUTHORIZATION);
headers.remove(WWW_AUTHENTICATE);
}
}
}
「跨域」就是 origin 不同:host 字符串、端口(没写端口时取 scheme 的默认端口)、scheme 三者有一个不同。比较的是下一跳和上一跳,不是和最初的请求。名单是固定的五个头,比 tower-http FilterCredentials 的三个(Authorization、Cookie、Proxy-Authorization)多了 Cookie2 和 WWW-Authenticate;名单之外的头一律照发。
实测(redirect headers),请求带 Authorization、Cookie、Proxy-Authorization、WWW-Authenticate 和一个自定义的 X-Api-Key;a.test、sub.a.test 用 resolve 指到 127.0.0.1:
## same origin
...
[A http] GET /to/302?loc=/ok authorization="Bearer secret" cookie="sid=1" proxy-authorization="Basic cHJveHk=" www-authenticate="Basic" x-api-key="k" body=0
[A http] GET /ok authorization="Bearer secret" cookie="sid=1" proxy-authorization="Basic cHJveHk=" www-authenticate="Basic" x-api-key="k" referer="http://127.0.0.1:62262/to/302?loc=/ok" body=0
## port change
...
[B http] GET /ok x-api-key="k" referer="http://127.0.0.1:62262/to/302?loc=http://127.0.0.1:62263/ok" body=0
## host 127.0.0.1 -> localhost, same port
...
[A http] GET /ok x-api-key="k" referer="http://127.0.0.1:62262/to/302?loc=http://localhost:62262/ok" body=0
## a.test -> sub.a.test
...
[A http] GET /ok x-api-key="k" referer="http://a.test:62262/to/302?loc=http://sub.a.test:62262/ok" body=0
## http -> https, same host and port
...
[S https] GET /ok x-api-key="k" referer="http://localhost:62264/to/302?loc=https://localhost:62264/ok" body=0
## https -> http, same host and port
...
[S http] GET /ok x-api-key="k" body=0
(除第一组外只留了跟随后的那一行;每组的第一个请求都带着全部五个头。)
- 同 origin 换路径:五个头全部保留。
- 换端口、
127.0.0.1换localhost(指向同一台机器)、子域名、只换 scheme:五个头都被去掉。host 比较的是字符串,不看解析结果,子域名也算别的 host。 X-Api-Key每一组都在。把 API key 放在自定义头里的客户端(不少 LLM API 就是这样),跨域重定向时 key 会跟着走;不想这样就设Policy::none()或在自定义策略里对跨域返回stop。- 请求里手写的
Cookie也在名单里会被去掉;cookie store 加的 cookie 走另一条路,见 cookie。
#3034(03db63a,「fix(redirect): strip sensitive headers on scheme change across redirects」,首次发布在 v0.13.4)。改之前 cross_host 只比 host 和 port_or_known_default()。从 https://h/ 跳到 http://h/ 时两边默认端口不同(443 和 80),本来就会被判成跨域;漏掉的是端口写明且相同的情形,例如 https://h:8443/ 跳到 http://h:8443/,Authorization 和 Cookie 会原样发到明文连接上。diff 只在条件里加了 || next.scheme() != previous.scheme(),外加单元测试 test_remove_sensitive_headers_on_scheme_downgrade_same_host_port。0.12 分支没有这个修复,0.12.28 的 remove_sensitive_headers 仍是两项比较。上面「same host and port」两组用的就是这种端口相同的地址。
Referer
ClientBuilder::referer 默认打开(Config 里 referer: true)。跟随时由 make_referer 生成:
fn make_referer(next: &Url, previous: &Url) -> Option<HeaderValue> {
if next.scheme() == "http" && previous.scheme() == "https" {
return None;
}
let mut referer = previous.clone();
let _ = referer.set_username("");
let _ = referer.set_password(None);
referer.set_fragment(None);
referer.as_str().parse().ok()
}
只在 https 跳 http 时不加;其余情况去掉用户名、密码和 fragment,path 和 query 完整保留,跨域也一样(上面「port change」那组,B 收到了 A 上的完整路径和查询串)。这比浏览器默认的 strict-origin-when-cross-origin 宽:浏览器跨域时只给 origin。它只作用于重定向产生的请求,reqwest 不会给第一个请求加 Referer。实测(redirect referer):
## referer default, with userinfo and query
...
[A http] GET /to/302?x=1&loc=/ok authorization="Basic dXNlcjpwdw==" body=0
[A http] GET /ok authorization="Basic dXNlcjpwdw==" referer="http://127.0.0.1:62296/to/302?x=1&loc=/ok" body=0
## referer https -> http
...
[S https] GET /to/302?loc=http://localhost:62298/ok body=0
[S http] GET /ok body=0
## referer http -> https
...
[S http] GET /to/302?loc=https://localhost:62298/ok body=0
[S https] GET /ok referer="http://localhost:62298/to/302?loc=https://localhost:62298/ok" body=0
## referer(false)
...
[A http] GET /to/302?loc=/ok body=0
[A http] GET /ok body=0
第一组的 URL 是 http://user:pw@127.0.0.1:.../to/302?x=1#frag:用户名密码变成了 Authorization: Basic(执行请求讲过),同 origin 跟随时照发;Referer 里没有 user:pw,也没有 #frag。
Location 不合规矩时
Location 先由 tower-http 按当前 URI 解析成绝对地址(resolve_uri,用的是 Url::join);解析失败或没有 Location,3xx 原样返回。scheme 检查在策略返回 Follow 之后,见上面 redirect 的代码。实测(redirect bad):
## Location: ftp://x/
Err display: builder error for url (ftp://x/)
Err debug: reqwest::Error { kind: Builder, url: "ftp://x/", source: BadScheme }
is_redirect=false is_builder=true url=Some("ftp://x/") chain: -> URL scheme is not allowed
...
## no Location
Ok 302 Found final=http://127.0.0.1:62305/s/302 location=None
...
## https_only, https -> http
Err display: error following redirect for url (http://localhost:62307/ok)
Err debug: reqwest::Error { kind: Redirect, url: "http://localhost:62307/ok", source: reqwest::Error { kind: Builder, url: "http://localhost:62307/ok", source: BadScheme } }
is_redirect=true is_builder=false url=Some("http://localhost:62307/ok") chain: -> builder error for url (http://localhost:62307/ok) -> URL scheme is not allowed
...
## none(), Location: ftp://x/
Ok 302 Found final=http://127.0.0.1:62305/to/302?loc=ftp://x/ location=Some("ftp://x/")
...
- 跳到
ftp://报的是Builder错误,is_redirect()为假,url()是Location指向的地址。同样是「scheme 不对」,https_only的那条却是Redirect包着Builder,is_redirect()为真。按is_redirect()分类的代码会漏掉前一种。 none()时策略先返回Stop,scheme 检查不会执行,ftp://的 302 原样返回。
整条链共用一个总超时:timeout 的计时器在 PendingRequest 里,包住的是整个 tower 栈的 future,每一跳不重新计时(见超时)。
动脑筋答案
## POST 307 stream body
Ok 307 Temporary Redirect final=http://127.0.0.1:62318/to/307?loc=/ok location=Some("/ok")
[A http] POST /to/307?loc=/ok transfer-encoding="chunked" body=5
返回 Ok,状态码 307;服务端只收到 1 个请求。流式 body 的 try_clone 返回 None,call 时 BodyRepr 就是 None;307 要求原样重发,take() 拿不到 body,poll 直接把 307 交了出去,策略的 redirect 没被调用,也没有错误。final 还是原来的 URL,Location 留在响应头里。连跑 3 次结果相同。
小结
- 循环是 tower-http 的
FollowRedirect;reqwest 只提供TowerRedirectPolicy:次数上限、scheme 检查、https_only、跨域去头、Referer、body 克隆。 Policy::default()是limited(10):跟 10 次,第 11 个 3xx 报Redirect错误,原因too many redirects只在source()里;none()把 3xx 原样返回;custom不防循环。- 301/302 只把 POST 改成 GET,303 一律改 GET(HEAD 除外),307/308 原样重发;流式 body 需要重发时,调用方拿到的是
Ok(3xx)。 - host 字符串、端口、scheme 任一不同就算跨域,比较的是相邻两跳;去掉
Authorization、Cookie、Cookie2、Proxy-Authorization、WWW-Authenticate,自定义头照发。只换 scheme 的情形从 0.13.4 起才算跨域。 Referer默认开,带完整 path 和 query,只有 https 跳 http 时不加。
FollowRedirect 的 Policy 组合子,以及 0.7.0 起扩展跟随重发、凭据过滤累积这两处变化(本卷用的 0.6.11 还没有),见 tower-http 卷 FollowRedirect 的策略。