Rust in Everything
基于 reqwest 0.13.5

cookie

主线位置:请求主线上的第三层。FollowRedirect 每发一跳都经过 CookieService:发出前从 cookie store 取出这个 URL 的 cookie 写进 Cookie 头,响应回来后把 Set-Cookie 交给 store。它下面是重试层,所以它只看得见每一跳的最终响应。

sequenceDiagram
  participant FR as FollowRedirect
  participant CS as CookieService
  participant St as CookieStore
  participant R as Retry
  FR->>CS: 一跳的请求
  CS->>St: cookies url
  St-->>CS: Cookie 头
  CS->>R: 请求
  R->>R: 503 被重试
  R-->>CS: 最后一次的响应
  CS->>St: set_cookies
  CS-->>FR: 响应

实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,卷里模板的 feature(含 cookies),release 构建。源站是 exp-reqwest/policy-lab 里的 hyper 服务器:?ck=... 让响应带上 Set-Cookie: ...,/to/302?loc= 重定向,/fail/1/503 第一次回 503、之后回 200;每个请求打一行日志,带上收到的 Cookie 头。同一端口既收明文也收 TLS。a.test、sub.a.test、secure.test 用 resolve 指到 127.0.0.1。客户端一律 .no_proxy()。

两道开关

cookie 支持要过两道开关。第一道是 Cargo feature cookies:

cookies = ["dep:cookie_crate", "dep:cookie_store"]

它拉进 cookie 和 cookie_store 两个 crate(模板解析到 0.18.2 和 0.22.1)。第二道是运行时:ClientBuilder 的 cookie_store(true) 或 cookie_provider(store):

    pub fn cookie_store(mut self, enable: bool) -> ClientBuilder {
        if enable {
            self.cookie_provider(Arc::new(cookie::Jar::default()))
        } else {
            self.config.cookie_store = None;
            self
        }
    }

cookie_store(true) 就是装一个新的空 Jar。两个方法写的是同一个字段,后调的覆盖先调的:先 cookie_provider(my_jar) 再 cookie_store(true),my_jar 就被换掉了,文档专门写了这一点。不调任何一个,config.cookie_store 是 None,CookieService 这一层仍在栈里,只是直接放行(一次请求看到的就是这种情况)。

没有 store 时,响应上的 cookie 照样能读:Response::cookies 只是解析这个响应的 Set-Cookie 头,解析失败的直接跳过,和 store 无关。

CookieStore 和 Jar

store 的接口是 CookieStore:

pub trait CookieStore: Send + Sync {
    /// Store a set of Set-Cookie header values received from `url`
    fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, url: &url::Url);
    /// Get any Cookie values in the store for `url`
    fn cookies(&self, url: &url::Url) -> Option<HeaderValue>;
}

两个方法都是 &self,store 在多个请求之间共享(Arc<dyn CookieStore>),内部可变性由实现自己负责。cookies 返回的是整个 Cookie 头的值,不是 cookie 列表:域名、路径、过期、Secure 怎么匹配,全由实现决定,reqwest 不插手。

默认实现 Jar 是 RwLock<cookie_store::CookieStore> 的薄包装:

impl CookieStore for Jar {
    fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, url: &url::Url) {
        let iter =
            cookie_headers.filter_map(|val| Cookie::parse(val).map(|c| c.0.into_owned()).ok());

        self.0.write().unwrap().store_response_cookies(iter, url);
    }

    fn cookies(&self, url: &url::Url) -> Option<HeaderValue> {
        let s = self
            .0
            .read()
            .unwrap()
            .get_request_values(url)
            .map(|(name, value)| format!("{name}={value}"))
            .collect::<Vec<_>>()
            .join("; ");

        if s.is_empty() {
            return None;
        }

        HeaderValue::from_maybe_shared(Bytes::from(s)).ok()
    }
}
  • 解析不了的 Set-Cookie 被 filter_map 静静丢掉,没有日志。
  • 匹配规则(domain、path、Secure、过期)都是 cookie_store crate 的:store_response_cookies 存、get_request_values 取。
  • 多个 cookie 拼成一个头,用 "; " 分隔。顺序不固定:同一组实验跑两遍,一次是 login=1; a=1,另一次是 a=1; login=1。
  • Jar 只有 Default 和 add_cookie_str 两个自己的方法,没有列举、删除、序列化的入口。文档建议需要这些时换用 reqwest_cookie_store crate,它实现同一个 trait。

预先放 cookie 的写法(cookies jar):

let jar = Arc::new(Jar::default());
let url: Url = u("/").parse().unwrap();
jar.add_cookie_str("pre=1; Path=/", &url);
let c = client().cookie_provider(jar.clone()).build().unwrap();
## preloaded Jar
  ...
    [A http] GET /ok cookie="pre=1" body=0
  jar.cookies(url) = Some("pre=1")

Arc 留在手里,之后随时可以 jar.cookies(&url) 查看。

CookieService:每一跳一次

CookieService 的 call:

    fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
        let clone = self.inner.clone();
        let mut inner = std::mem::replace(&mut self.inner, clone);
        let url = Url::parse(req.uri().to_string().as_str()).expect("invalid URL");
        if let Some(cookie_store) = self.cookie_store.as_ref() {
            if req.headers().get(crate::header::COOKIE).is_none() {
                let headers = req.headers_mut();
                crate::util::add_cookie_header(headers, &**cookie_store, &url);
            }
        }

        let cookie_store = self.cookie_store.clone();
        ResponseFuture {
            future: inner.call(req),
            cookie_store,
            url,
        }
    }

它的 future 在内层返回 Ok(res) 后,把 res 上所有 Set-Cookie 交给 set_cookies(.., url);内层返回错误时什么都不做。两个细节:

  • 请求里已经有 Cookie 头(用户自己 .header("cookie", ..) 写的),store 就完全不参与,不合并。
  • url 是这一跳的 URL。CookieService 在 FollowRedirect 里面,重定向的每一跳都单独调一次 call:302 响应上的 Set-Cookie 先存进去,下一跳发出前再按新 URL 取。

实测(cookies basic,每个 Set-Cookie 都带 Path=/):

## no store: Set-Cookie a=1
  Ok 200 OK final=http://127.0.0.1:63919/s/200?ck=a=1%3B%20Path=/ resp.cookies()=["a=1"]
    [A http] GET /s/200?ck=a=1%3B%20Path=/ body=0
## no store: next request
  ...
    [A http] GET /ok body=0
## store: Set-Cookie a=1
  ...
    [A http] GET /s/200?ck=a=1%3B%20Path=/ body=0
## store: next request
  ...
    [A http] GET /ok cookie="a=1" body=0
## store: manual Cookie header
  ...
    [A http] GET /ok cookie="m=1" body=0
## store: Set-Cookie on 302, then hop
  ...
    [A http] GET /to/302?ck=login=1%3B%20Path=/&loc=/ok cookie="a=1" body=0
    [A http] GET /ok cookie="a=1; login=1" referer="http://127.0.0.1:63919/to/302?ck=login=1%3B%20Path=/&loc=/ok" body=0
## store: redirect 127.0.0.1 -> localhost
  ...
    [A http] GET /to/302?loc=http://localhost:63919/ok cookie="a=1; login=1" body=0
    [A http] GET /ok referer="http://127.0.0.1:63919/to/302?loc=http://localhost:63919/ok" body=0
  • 302 上设的 login=1,同一个请求的下一跳就带上了。
  • 从 127.0.0.1 跳到 localhost,下一跳一个 cookie 都没有:store 按 host 匹配,127.0.0.1 的 cookie 不属于 localhost。这里起作用的是 store 的匹配,不是重定向一页讲的 remove_sensitive_headers:store 加的 Cookie 头每一跳都是新取的。重定向层去掉的是用户手写的 Cookie 头;手写的头被去掉之后,CookieService 看到请求里没有 Cookie,会按新 URL 从 store 里取。

域名、Secure 和路径

这些规则都在 cookie_store crate 里(RFC 6265),这里只记几条用 reqwest 时容易碰到的。实测(cookies domain):

## a.test sets host-only h=1 and redirects to sub.a.test
  ...
    [A http] GET /to/302?ck=h=1%3B%20Path=/&loc=http://sub.a.test:63919/ok body=0
    [A http] GET /ok referer="http://a.test:63919/to/302?ck=h=1%3B%20Path=/&loc=http://sub.a.test:63919/ok" body=0
## a.test sets d=1; Domain=a.test and redirects to sub.a.test
  ...
    [A http] GET /to/302?ck=d=1%3B%20Domain=a.test%3B%20Path=/&loc=http://sub.a.test:63919/ok cookie="h=1" body=0
    [A http] GET /ok cookie="d=1" referer="http://a.test:63919/to/302?ck=d=1%3B%20Domain=a.test%3B%20Path=/&loc=http://sub.a.test:63919/ok" body=0
## http sets s=1; Secure
  ...
    [S http] GET /s/200?ck=s=1%3B%20Secure%3B%20Path=/ body=0
## https sets t=1; Secure
  ...
    [S https] GET /s/200?ck=t=1%3B%20Secure%3B%20Path=/ cookie="s=1" body=0
## then http
  ...
    [S http] GET /ok body=0
## then https
  ...
    [S https] GET /ok cookie="t=1; s=1" body=0
## localhost http sets l=1; Secure
  ...
    [A http] GET /s/200?ck=l=1%3B%20Secure%3B%20Path=/ body=0
## then localhost http
  ...
    [A http] GET /ok cookie="l=1" body=0
  • 不带 Domain 的 cookie 只回给原 host,子域名拿不到;带 Domain=a.test 的,sub.a.test 也拿得到。
  • Secure cookie 只在 https 上发出。从明文 http 收到的 Secure cookie(s=1)也被存下了,之后在 https 上发出。
  • localhost 和环回地址例外:cookie_store 的 is_secure 把 localhost、127.0.0.1、::1 上的 http 也当成安全的,所以本机测试看不出 Secure 的效果,上面那组改用 secure.test。
  • 没写 Path 的 cookie,路径默认是请求路径去掉最后一段,见下面的动脑筋。
❗IMPORTANT
动脑筋:开了 cookie_store(true),先 GET /api/s/200,响应带 Set-Cookie: sid=1(不带 Path)。接着同一个客户端依次请求 /api/me 和 /api/s/me。服务端在这两个请求上各看到什么 Cookie 头?平台:M2 Max、macOS 27,reqwest 0.13.5,明文 HTTP/1.1。

栈从外到内是 FollowRedirect → CookieService → Retry → HyperService。CookieService 在重试层外面,它只看到重试层交上来的那一个响应。按状态码重试时,前面几次的响应在重试层里就被丢了,上面的 Set-Cookie 永远到不了 store。实测(cookies retry,分类器:503 判可重试,/fail/1/503 第一次回 503 并设 cookie,第二次回 200 不设):

## no retry: 503 sets q=1
  Ok 503 Service Unavailable final=http://127.0.0.1:63957/fail/1/503?ck=q=1%3B%20Path=/ resp.cookies()=["q=1"]
    [A http] GET /fail/1/503?ck=q=1%3B%20Path=/ body=0
## no retry: next request
  ...
    [A http] GET /ok cookie="q=1" body=0
## retry 503: 503 sets r=1, retried to 200
  Ok 200 OK final=http://127.0.0.1:63957/fail/1/503?ck=r=1%3B%20Path=/ resp.cookies()=[]
    [A http] GET /fail/1/503?ck=r=1%3B%20Path=/ body=0
    [A http] GET /fail/1/503?ck=r=1%3B%20Path=/ body=0
## retry 503: next request
  ...
    [A http] GET /ok body=0

不重试时,503 上的 q=1 存下了,下一个请求带着它。重试时服务端收到两次请求,第一次的 503 设了 r=1,但下一个请求没有 Cookie。

换一个打印每次调用的 store(实现 CookieStore,转发给 Jar),走一条「302 设 x=1 → /fail/1/503 设 y=1 → 重试成 200」的链(cookies loud):

## loud: 302 sets x=1 -> /fail/1/503 sets y=1 -> retried
    store.cookies(/to/302) -> None
    store.set_cookies(/to/302, ["x=1; Path=/"])
    store.cookies(/fail/1/503) -> Some("x=1")
  Ok 200 OK final=http://127.0.0.1:63961/fail/1/503?ck=y=1%3B%20Path=/ resp.cookies()=[]
    [A http] GET /to/302?ck=x=1%3B%20Path=/&loc=/fail/1/503?ck=y=1%3B%20Path=/ body=0
    [A http] GET /fail/1/503?ck=y=1%3B%20Path=/ cookie="x=1" referer="http://127.0.0.1:63961/to/302?ck=x=1%3B%20Path=/&loc=/fail/1/503?ck=y=1%3B%20Path=/" body=0
    [A http] GET /fail/1/503?ck=y=1%3B%20Path=/ cookie="x=1" referer="http://127.0.0.1:63961/to/302?ck=x=1%3B%20Path=/&loc=/fail/1/503?ck=y=1%3B%20Path=/" body=0

每一跳 cookies 一次、set_cookies 至多一次;第二跳发了两次,cookies 只被问了一次,重发的请求是重试层克隆的,Cookie 头原样照抄(重试的 clone_request 复制全部请求头);y=1 从没进过 set_cookies。

Client 默认的重试策略只在没拿到响应时重试(HTTP/2 的 REFUSED_STREAM、GOAWAY),没有响应也就没有 Set-Cookie 可丢;只有自己写了按状态码重试的分类器才会碰到。服务端在 503、429 上设会话 cookie(比如限流或排队令牌)时,要么别在分类器里重试这些状态码,要么自己从 Response::cookies 里取。

📝NOTE
冷知识:cookie 曾经在重试层里面

#2787(da0702b,「refactor(cookie): de-duplicate cookie support as CookieService middleware」,首次发布在 v0.12.24),提交说明没有正文。改之前,cookie 的读写写在 HyperService::call 里(#[cfg(feature = "cookies")] 一份 call,没开 feature 另一份),HTTP/3 的 H3Client 里又有一份相同的代码。HyperService 在 Retry 里面,所以每一次尝试都会加 Cookie 头、存 Set-Cookie。diff 把这两份删掉,合成 cookie.rs 里的 CookieService,在 build 里插到 Retry 和 FollowRedirect 之间:let svc = CookieService::new(svc, config.cookie_store.clone());。重复代码没了,cookie 层也从重试层里面挪到了外面,上一节的现象就从这时开始。retry 模块是 v0.12.23 加的,所以「按状态码重试,cookie 每次都存」只在 v0.12.23 这一个版本里成立。

动脑筋答案

## login at /api/s/200 sets sid=1 (no Path)
  Ok 200 OK final=http://127.0.0.1:63919/api/s/200?ck=sid=1 resp.cookies()=["sid=1"]
    [A http] GET /api/s/200?ck=sid=1 body=0
## then /api/me
  Ok 200 OK final=http://127.0.0.1:63919/api/me resp.cookies()=[]
    [A http] GET /api/me body=0
## then /api/s/me
  Ok 200 OK final=http://127.0.0.1:63919/api/s/me resp.cookies()=[]
    [A http] GET /api/s/me cookie="sid=1" body=0

/api/me 上没有 Cookie 头,/api/s/me 上是 sid=1。没写 Path 时,默认路径是请求路径到最后一个 / 之前的部分(RFC 6265 第 5.1.4 节的 default-path):/api/s/200 的默认路径是 /api/s,只有 /api/s 和它下面的路径匹配。登录接口在 /api/auth/login、业务接口在 /api/... 时,服务端忘了写 Path=/,reqwest 就不会把会话 cookie 发给业务接口;浏览器的规则相同。连跑 3 次结果相同。

小结

  • 要同时打开 cookies feature 和 cookie_store(true)(或 cookie_provider);两个 builder 方法写同一个字段,后调的覆盖先调的。Response::cookies() 不需要 store。
  • CookieStore 只有 set_cookies 和 cookies 两个方法,匹配规则全在实现里;默认的 Jar 是 RwLock<cookie_store::CookieStore>,解析失败的 Set-Cookie 静静丢掉,拼出的 Cookie 顺序不固定,不能列举或序列化。
  • CookieService 在 FollowRedirect 里面,每一跳按这一跳的 URL 取一次、存一次;302 上设的 cookie 下一跳就能用。请求里已有 Cookie 头时 store 不参与。
  • 换 host 后 store 里的 cookie 不跟过去;带 Domain 的 cookie 会发给子域名;Secure 只在 https(以及 localhost、环回地址)上发;没写 Path 时默认路径是请求路径的「目录」。
  • CookieService 在 Retry 外面:按状态码重试时,被重试掉的响应上的 Set-Cookie 丢失,重发的请求沿用第一次的 Cookie 头。默认重试策略不受影响。这个顺序来自 v0.12.24 的 #2787。

💬 关联讨论

发起讨论
加载中...