mini-client
主线位置:三条主线各走最短的一段。请求主线:
send起一个总超时,进重定向循环,把请求交给 legacyClient;连接主线:池里没有连接时调自己写的HttpsConnector,TCP 之后做 TLS,按 ALPN 告诉池子这是不是 HTTP/2;响应主线:非重定向的响应把 body 一次读完,再按需解析 JSON。
flowchart TB
send["send 起总超时"] --> follow["follow 循环"]
follow --> once["once 发一次"]
once --> legacy["legacy Client"]
legacy --> pool{"池里有连接"}
pool -- 有 --> wire["写请求读响应头"]
pool -- 没有 --> conn["HttpsConnector"]
conn --> tcp["HttpConnector"]
tcp --> tls["tokio-rustls"]
tls --> h2flag["negotiated_h2"]
h2flag --> wire
wire --> redir{"3xx 且有 Location"}
redir -- 是 --> follow
redir -- 否 --> read["读完 body"]
本页的代码在 exp-reqwest/site-mini,一个库加一个测试文件。库 295 行(去掉空行和注释 243 行),只依赖这些:
[dependencies]
hyper = { version = "1", features = ["client", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2", "tokio"] }
http-body-util = "0.1"
bytes = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
tower-service = "0.3"
url = "2"
serde = "1"
serde_json = "1"
锁文件解析到 hyper 1.12.0、hyper-util 0.1.21、tokio-rustls 0.26.6、rustls 0.23.46、ring 0.17.14、url 2.5.8,和 reqwest 0.13.5 底下的 hyper、hyper-util、rustls 是同一版本。provider 用 ring(reqwest 0.13 默认是 aws-lc-rs),对本页的代码没有影响。测试用共享的 testsrv(明文和 HTTPS,HTTP/1.1 和 HTTP/2,/echo、/redirect/N、?head_ms= 等行为见它的 README)。平台:M2 Max、macOS 27、rustc 1.99.0,--release。
连接器:一个 Service<Uri>
legacy Client 不管连接怎么建,它只要一个 tower::Service<Uri>,返回实现了 hyper 的 Read、Write 和 hyper-util 的 Connection 的流。hyper-util 自带的 HttpConnector 满足这个要求,但只会 TCP;HTTPS 要在它外面再包一层:
impl Service<Uri> for HttpsConnector {
type Response = MaybeTls;
type Error = BoxError;
type Future = Pin<Box<dyn Future<Output = Result<MaybeTls, BoxError>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
self.http.poll_ready(cx).map_err(Into::into)
}
fn call(&mut self, uri: Uri) -> Self::Future {
let mut http = self.http.clone();
let tls = self.tls.clone();
Box::pin(async move {
let https = uri.scheme_str() == Some("https");
let host = uri.host().ok_or("uri without host")?.trim_matches(['[', ']']).to_string();
let tcp = http.call(uri).await?;
if !https {
return Ok(MaybeTls::Plain(tcp));
}
let name = ServerName::try_from(host)?;
let stream = tls.connect(name, tcp.into_inner()).await?;
Ok(MaybeTls::Tls(TokioIo::new(stream)))
})
}
}
HttpConnector默认拒绝非http的 URI,构造时要enforce_http(false)。DNS、Happy Eyeballs、TCP 选项都还是它的,见 hyper 卷的 HttpConnector。Future必须是Unpin + Send,所以装进Pin<Box<...>>。reqwest 的Connector也是这样做的。MaybeTls是TokioIo<TcpStream>和TokioIo<TlsStream<TcpStream>>的枚举,Read/Write的四个方法逐个转发,占了库里约 30 行。reqwest 在connect.rs里为每种组合(TCP、Unix socket、经代理、native-tls、rustls)各写一个Conn包装,做的是同一件事。
比转发重要的是 Connection。连接池要知道 ALPN 选了什么:
impl Connection for MaybeTls {
fn connected(&self) -> Connected {
match self {
MaybeTls::Plain(s) => s.connected(),
MaybeTls::Tls(s) => {
let (tcp, session) = s.inner().get_ref();
// The pool keys HTTP/2 connections differently: tell it what ALPN picked.
if session.alpn_protocol() == Some(b"h2") {
tcp.connected().negotiated_h2()
} else {
tcp.connected()
}
}
}
}
}
legacy Client 在 connect_to 里读 connected.alpn:是 H2 就用 HTTP/2 握手,并把这次取连接改成 HTTP/2 的共享取法;否则按 HTTP/1.1。reqwest 的 rustls 分支是同一段判断,见 impl Connection for RustlsTlsConn。
问: 服务端已经在 ALPN 里选了 h2,客户端不调 negotiated_h2(),hyper-util 自己看不出来吗?
答: 看不出来。它拿到的只是一个能读写字节的流,ALPN 的结果在 rustls 的会话对象里,只有连接器能告诉它。把那个 if 改成 if false && ... 再跑,对一个只接受 h2 的 testsrv(Proto::H2):
== without negotiated_h2
error: request failed: client error (SendRequest)
caused by: client error (SendRequest)
caused by: invalid HTTP version parsed
== with
HTTP/2.0 hello https://localhost:64565/ (conn #1 req #1 total #1)
客户端在一条已经约定为 h2 的连接上写了 HTTP/1.1 请求,服务端回的是 HTTP/2 的帧,客户端按 HTTP/1.1 解析,失败。对 Proto::Auto 的服务端倒是能「成功」,因为 hyper-util 的 auto 服务端按前几个字节识别协议、不看 ALPN(见 hyper 卷的 auto),但并发 10 个请求就开 10 条 HTTP/1.1 连接,预热之后也一样。
Client::new:TLS 配置和连接池
pub fn new(roots: rustls::RootCertStore, timeout: Duration) -> Result<Client, Error> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut tls = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(Error::Tls)?
.with_root_certificates(roots)
.with_no_client_auth();
tls.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let mut http = HttpConnector::new();
http.enforce_http(false); // we hand it https:// URIs too
http.set_nodelay(true);
let connector = HttpsConnector { http, tls: TlsConnector::from(Arc::new(tls)) };
let inner = Legacy::builder(TokioExecutor::new())
.pool_timer(TokioTimer::new())
.timer(TokioTimer::new())
.build(connector);
Ok(Client { inner, timeout })
}
builder_with_provider显式给 provider,不走进程默认值,所以不管依赖图里编进几个 provider 都不会 panic(这个坑见 rustls 卷的 CryptoProvider)。reqwest 先get_default()、没有才用自己的默认,见 TLS 配置。- 根证书只认调用方给的
RootCertStore。测试里只放testsrv的 CA;拿空集合去连,错误链的最后一环是 rustls 的原话:
test unknown_ca_is_rejected ... request failed: client error (Connect)
caused by: client error (Connect)
caused by: invalid peer certificate: UnknownIssuer
pool_timer让池子在后台清理空闲连接,timer给 HTTP/2 的 keep-alive 用;reqwest 在build里给 legacyClient配的也是这两个,见 hyper 卷本站的出站客户端的表。空闲超时、每主机上限用 hyper-util 的默认值(90 秒、不限),和 reqwest 的默认值相同。Client派生Clone:legacyClient内部就是Arc,克隆共享同一个池。
测试对同一个 HTTPS testsrv 连发三个请求,Proto::Auto(ALPN 给 h2、http/1.1)和 Proto::H1(只给 http/1.1)各一组:
test https_alpn_and_connection_reuse ... Auto HTTP/2.0 hello https://localhost:64670/a (conn #1 req #1 total #1)
Auto HTTP/2.0 hello https://localhost:64670/b (conn #1 req #2 total #2)
Auto HTTP/2.0 hello https://localhost:64670/c (conn #1 req #3 total #3)
Auto: conns=1 handshakes=1 reqs=3
H1 HTTP/1.1 hello /a (conn #1 req #1 total #1)
H1 HTTP/1.1 hello /b (conn #1 req #2 total #2)
H1 HTTP/1.1 hello /c (conn #1 req #3 total #3)
H1: conns=1 handshakes=1 reqs=3
ok
两组都是一条连接、一次握手。连接复用这一项完全是 hyper-util 的连接池给的,库里没有一行代码管它。
一个超时管全程
/// The clock starts here and stops when the last body byte is in.
pub async fn send(&self, method: Method, url: &str, headers: HeaderMap, body: Bytes) -> Result<Response, Error> {
let url = Url::parse(url).map_err(|e| Error::Url(e.to_string()))?;
match tokio::time::timeout(self.timeout, self.follow(method, url, headers, body)).await {
Ok(result) => result,
Err(_) => Err(Error::Timeout(self.timeout)),
}
}
follow 里包括所有重定向和最后一次读 body,所以一个 tokio::time::timeout 就覆盖了连接、等响应头、读响应体三段。代价是 Response 只能拿到读完的 Bytes:body 一旦交给调用方流式读,计时就得跟着 body 走,reqwest 为此把 Sleep 塞进 TotalTimeoutBody,见超时。
测试让 testsrv 分别在响应头之前睡 1000 ms、在三个 body 块之间各睡 400 ms,总超时 300 ms:
test total_timeout_covers_head_and_body ... /?head_ms=1000: timed out after 300ms after 303 ms
/?chunks=3&body_ms=400: timed out after 300ms after 302 ms
ok
连跑 3 次(uptime 的 1 分钟负载约 5),两种情况都在 302 到 303 ms 返回。第二种情况里响应头早就到了,超时发生在读 body 的中途,这正是只设 connect_timeout 时漏掉的那一段。
重定向
async fn follow(&self, mut method: Method, mut url: Url, mut headers: HeaderMap, mut body: Bytes) -> Result<Response, Error> {
for _ in 0..=MAX_REDIRECTS {
let resp = self.once(&method, &url, &headers, body.clone()).await?;
// Same method rules as tower-http's FollowRedirect; other statuses end here.
let to_get = match resp.status() {
StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => method == Method::POST,
StatusCode::SEE_OTHER => true,
StatusCode::TEMPORARY_REDIRECT | StatusCode::PERMANENT_REDIRECT => false,
_ => return read(resp, url).await,
};
let location = resp.headers().get(header::LOCATION).and_then(|l| l.to_str().ok());
let Some(next) = location.and_then(|l| url.join(l).ok()) else {
return read(resp, url).await;
};
// Read the (small) redirect body so an HTTP/1 connection goes back to the pool.
resp.into_body().collect().await.map_err(Error::Body)?;
if to_get {
if method != Method::HEAD {
method = Method::GET;
}
body = Bytes::new();
headers.remove(header::CONTENT_TYPE);
headers.remove(header::CONTENT_LENGTH);
}
if origin(&next) != origin(&url) {
for name in &SENSITIVE {
headers.remove(name);
}
}
url = next;
}
Err(Error::TooManyRedirects(url))
}
- 最多 5 次。 循环最多发 6 个请求,第 6 个还是重定向就报错。
- 方法规则抄 tower-http。 301、302 只把 POST 改成 GET,303 除 HEAD 外都改成 GET,307、308 原样重发。改成 GET 时丢掉 body 和两个描述 body 的头。请求体是
Bytes,每次clone只加引用计数,所以 307 总能重发;reqwest 的流式 body 不能克隆,遇到 307 只能把 3xx 交回给调用方,见重定向。 - 跨源去头。
origin是(scheme, host_str, port_or_known_default),三者都相同才算同源;SENSITIVE是Authorization、Cookie、Proxy-Authorization、WWW-Authenticate,即 reqwestremove_sensitive_headers的列表去掉已经废弃的Cookie2。比较的是上一跳和下一跳,和 reqwest 一样。 - 读完重定向响应的 body。 HTTP/1.1 下 body 没读完就丢,连接不能回池。读完以后整条重定向链用一条连接,测试里
/redirect/5的最后一跳答的是conn #1。
测试输出:
test credentials_stay_on_their_origin ... same origin http://127.0.0.1:64662/echo ["authorization: Bearer secret", "cookie: sid=1"]
other port http://127.0.0.1:64663/echo []
localhost http://localhost:64662/echo []
ok
test five_redirects_on_one_connection_then_stop ... 200 OK http://127.0.0.1:64668/redirect/0 -> redirect chain done (conn #1)
more than 5 redirects, next http://127.0.0.1:64668/redirect/0
ok
test see_other_becomes_get_temporary_redirect_replays ... 303: GET /echo HTTP/1.1 | - | body: 0 bytes
307: POST /echo HTTP/1.1 | content-type: application/json | body: 7 bytes
ok
localhost 那一行和原请求同一个端口、同一个进程,只是主机字符串从 127.0.0.1 换成了 localhost,也算跨源。为了确认这个测试真的能抓住问题,把 if origin(&next) != origin(&url) 临时改成 if false && ...,它就失败在 left: 2, right: 0(两个凭据头被带到了另一个端口)。
JSON 和错误
pub fn json<T: DeserializeOwned>(&self) -> Result<T, Error> {
serde_json::from_slice(&self.body).map_err(|source| Error::Json {
source,
body: String::from_utf8_lossy(&self.body[..self.body.len().min(200)]).into_owned(),
})
}
因为 body 已经整个在 Response 里,解码失败时顺手把前 200 字节放进错误。对 testsrv 的纯文本响应调 json():
test json_round_trip_and_readable_decode_errors ... Echo { ok: true, n: 3 }
json: expected value at line 1 column 1; body starts "hello /not-json (conn #1 req #1 total #1)"
ok
reqwest 的 json() 读完 body 就交给 serde_json,失败时 body 已经丢了,错误里只有 serde_json 的位置信息(0.13.4 起加上了 URL),想看原文得先 text() 再自己解析,见响应体。反过来,Error 只有七个变体,没有 is_timeout()、is_connect() 这类分类,连接阶段和发送阶段的失败都装在 Send 里,调用方要自己沿 source() 往下找;reqwest 的分法见错误。
post_json 只做两件事:serde_json::to_vec,插入 Content-Type: application/json。
比 reqwest 少了什么
7 个测试都通过,但这只是 reqwest 的一个子集。逐项列出缺的东西:
| 缺的 | reqwest 怎么做 | 讲它的页面 |
|---|---|---|
| 请求构建与延迟报错 | RequestBuilder 把错误存到 send 时才报,header、query、form、basic_auth 一应俱全 | 请求构建 |
| 默认头 | 补 accept: */*,HTTP 目标经代理时补代理认证 | 执行与 Pending |
| 流式请求体、multipart | Body 有完整和流式两种形态,multipart 生成边界和长度 | 请求体与 multipart |
| 可替换的连接器层 | connector_layer 在连接器外套 tower 层 | 连接器 |
| DNS 定制 | Resolve trait、hickory、resolve() 覆盖 | DNS |
| TLS 选择 | rustls 或 native-tls,平台校验器、客户端证书、版本范围、tls_info | TLS 配置 |
| 代理 | 显式代理、系统代理、NO_PROXY、CONNECT 隧道、SOCKS | 代理 |
| 池与协议配置 | 空闲超时、每主机上限、http2_prior_knowledge、HTTP/3 | 连接池与 HTTP 版本 |
| 流式读响应、解压、字符集 | chunk、bytes_stream,tower-http 解压层,按 charset 解码 | 响应体 |
| 分段超时 | connect_timeout、read_timeout,总超时跟着 body 走 | 超时 |
| 可配置的重定向 | redirect::Policy(默认 10 次、自定义闭包)、Referer、https_only | 重定向 |
| 重试 | 默认重试 HTTP/2 的 REFUSED_STREAM 和 GOAWAY,可配预算 | 重试 |
| 错误分类 | Kind 加 is_timeout / is_connect / is_status,带 URL | 错误 |
| cookie | Jar、CookieStore,跨重定向 | cookie |
| 阻塞接口 | 后台线程跑运行时,经通道转发 | 阻塞客户端 |
| WASM | 浏览器里直接调 fetch | WASM |
reqwest 的 src/ 在 tag 上一共 20323 行,这里是 295 行。差出来的部分,大多是上表里的选项和三套实现(异步、阻塞、WASM)共用的类型;真正「把请求送出去、把响应拿回来」的那一段,hyper-util 已经替两边都做了。
服务端是 Proto::Auto 的 testsrv(ALPN 给 h2 和 http/1.1)。10 个任务各拿一份 c.clone(),同时 get,收集响应里的 x-conn。再做一组:先发一个请求预热,再并发 10 个。代码在 examples/race.rs。
动脑筋答案
连跑 3 次:
warm=false: versions=["HTTP/2.0"] answered on conn ["1"]; conns=10 tls_handshakes=10 reqs=10
warm=true: versions=["HTTP/2.0"] answered on conn ["1"]; conns=1 tls_handshakes=1 reqs=11
warm=false: versions=["HTTP/2.0"] answered on conn ["1"]; conns=10 tls_handshakes=10 reqs=10
warm=true: versions=["HTTP/2.0"] answered on conn ["1"]; conns=1 tls_handshakes=1 reqs=11
warm=false: versions=["HTTP/2.0"] answered on conn ["7"]; conns=10 tls_handshakes=10 reqs=10
warm=true: versions=["HTTP/2.0"] answered on conn ["1"]; conns=1 tls_handshakes=1 reqs=11
- 冷启动:10 条 TCP、10 次 TLS 握手,但 10 个请求全落在同一条连接上(第三次是 #7)。10 个请求同时到池里,池里什么都没有,也还不知道这个主机会说 HTTP/2,于是每个请求各自起一次连接。第一个握手完成、ALPN 报出
h2的连接通过alpn_h2把这次取连接改成共享的 HTTP/2 连接,其余请求都排到它上面;后到的 9 条连接在connect_to里拿不到锁,以「ALPN upgraded to HTTP/2」取消。哪条先握完手,取决于调度,所以第三次赢的是 #7。 - 预热以后:池里已经有一条 HTTP/2 连接,10 个请求直接多路复用,只有 1 条连接、1 次握手。
这和 reqwest 一模一样,因为这段逻辑在 hyper-util 的 legacy Client 里,不在 reqwest:连接池与 HTTP 版本用 reqwest 并发 100 个请求测到的是 100 次握手、只用 1 条。机制见 hyper 卷连接池的「和新建连接赛跑」一节。
小结
- hyper-util 的 legacy
Client已经包办了连接池、HTTP/1.1 和 HTTP/2、Host 头和 URI 形式;自己要写的是一个Service<Uri>连接器,HTTPS 时在 TCP 上套 TLS,并通过Connection::connected用negotiated_h2报告 ALPN 的结果。漏掉这一步,h2 连接上会写出 HTTP/1.1 请求。 - 一个
tokio::time::timeout包住重定向循环和读 body,就是覆盖全程的总超时;代价是只能返回读完的 body。卡在响应头和卡在响应体都在 302 到 303 ms 结束。 - 重定向约 35 行:最多 5 次,方法规则和 tower-http 相同,跨 scheme、主机、端口时去掉
Authorization等四个头,读完中间响应的 body 让连接回池。 - 295 行对 20323 行:reqwest 多出来的是代理、DNS、TLS 选择、分段超时、解压、重试、cookie、错误分类、阻塞和 WASM 接口,每一项都有一页。