HttpConnector
主线位置:客户端主线上「池里没有可用连接」的那一支。连接池里的
connect_to把 pool key 拼回一个只有 scheme 和 authority 的Uri,交给连接器;HttpConnector做 DNS 和 TCP,交回一个TokioIo<TcpStream>,之后才是 HTTP 握手(连接级客户端)。
flowchart TB
uri["Uri"] --> hp{"get_host_port:scheme 检查"}
hp -- 不合格 --> err1["ConnectError"]
hp --> ip{"host 是 IP 字面量?"}
ip -- 是 --> addrs["SocketAddrs"]
ip -- 否 --> dns["resolver:默认 getaddrinfo"]
dns --> addrs
addrs --> split["按第一个地址的协议族分两组"]
split --> pref["首选组:逐个连"]
split --> fb["备用组:延迟到期或首选失败"]
pref --> tcp["TcpStream"]
fb --> tcp
tcp --> io["set_nodelay,包成 TokioIo"]
源码在 client/legacy/connect/http.rs、dns.rs 和 mod.rs。它直接用 tokio 的 TcpSocket 和 tokio::time,所以只在 hyper-util 的 tokio feature 下存在,不走 hyper 的 rt 抽象。
连接器就是一个 Service<Uri>
legacy Client 对连接器的要求写在 sealed trait Connect 上,它对所有满足条件的 tower Service<Uri> 有一个通用实现:
impl<S, T> Connect for S
where
S: tower_service::Service<Uri, Response = T> + Send + 'static,
S::Error: Into<Box<dyn StdError + Send + Sync>>,
S::Future: Unpin + Send,
T: Read + Write + Connection + Unpin + Send + 'static,
{
type _Svc = S;
fn connect(self, _: Internal, dst: Uri) -> crate::service::Oneshot<S, Uri> {
crate::service::Oneshot::new(self, dst)
}
}
也就是说:任何 Service<Uri>,返回的 IO 实现了 hyper 的 Read + Write(见 IO trait)和 Connection,future 是 Unpin + Send,就能当连接器。每次建连接,Client 克隆一份连接器,用 Oneshot 先 poll_ready 再 call,和 tower 的 ServiceExt::oneshot 是同一个意思。连接池一页的赛跑实验就是用 tower::service_fn 包了一个慢 300ms 的连接器(Box::pin 是为了满足 Unpin)。
Connection::connected 返回 Connected,告诉 Client 这条连接的情况:ALPN 是否协商出 h2(决定用 HTTP/1 还是 HTTP/2 握手,以及连接池的 HTTP/2 锁)、是否经过代理(决定请求行写绝对形式还是 origin 形式)、一个附加值 extra、以及 poison 标记。TcpStream 的实现把 HttpInfo(本地和远端地址)放进 extra,Client 把它插进每个响应的 extensions,所以 res.extensions().get::<HttpInfo>() 能拿到对端地址。
HttpConnector 自己的 Service 实现:Response = TokioIo<TcpStream>,poll_ready 转发给 resolver,call 克隆自身,把 call_async 装箱:
async fn call_async(&mut self, dst: Uri) -> Result<TokioIo<TcpStream>, ConnectError> {
let config = &self.config;
let (host, port) = get_host_port(config, &dst)?;
let host = crate::client::strip_ipv6_brackets(host);
// If the host is already an IP addr (v4 or v6),
// skip resolving the dns and start connecting right away.
let addrs = if let Some(addrs) = dns::SocketAddrs::try_parse(host, port) {
addrs
} else {
let addrs = resolve(&mut self.resolver, dns::Name::new(host.into()))
.await
.map_err(ConnectError::dns)?;
let addrs = addrs
.map(|mut addr| {
set_port(&mut addr, port, dst.port().is_some());
addr
})
.collect();
dns::SocketAddrs::new(addrs)
};
let c = ConnectingTcp::new(addrs, config);
let sock = c.connect().await?;
if let Err(e) = sock.set_nodelay(config.nodelay) {
warn!("tcp set_nodelay error: {}", e);
}
Ok(TokioIo::new(sock))
}
call_async 就是本页的目录:检查 Uri、解析地址、建 TCP、设 nodelay。配置都在一个 Arc<Config> 里,克隆连接器只是加一次引用计数;set_* 方法用 Arc::make_mut,改的时候如果有别的克隆,会先复制一份。
第一步:检查 Uri
if config.enforce_http {
if dst.scheme() != Some(&Scheme::HTTP) {
return Err(ConnectError {
msg: INVALID_NOT_HTTP,
addr: None,
cause: None,
});
}
} else if dst.scheme().is_none() {
return Err(ConnectError {
msg: INVALID_MISSING_SCHEME,
addr: None,
cause: None,
});
}
enforce_http 默认开:scheme 不是 http 就报错,不解析、不连接。关掉以后只要求有 scheme。随后取 host(没有就报 invalid URL, host is missing),端口没写时 https 用 443、其余用 80。三条报错原文是 三个静态字符串。做 SOCKS 代理的内层连接器时也要关掉它:SocksV5 交给内层的是 socks5:// 代理地址,默认的 HttpConnector 会拒绝,外面只看得到 SOCKS error: failed to create underlying connection,见 这个坑 和 代理连接器。
拿一个 https 地址分别直接调连接器、通过 Client 发请求:
hyper = "=1.12.0"
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "0.1"
tokio = { version = "=1.54.1", features = ["full"] }
tower = { version = "=0.5.3", features = ["util"] }
use std::error::Error;
use http_body_util::Empty;
use hyper::{body::Bytes, Uri};
use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::TokioExecutor,
};
use tower::ServiceExt;
#[tokio::main]
async fn main() {
// 1. The connector on its own.
let uri: Uri = "https://localhost:1/".parse().unwrap();
let err = HttpConnector::new().oneshot(uri.clone()).await.unwrap_err();
println!("connector Display: {err}");
println!("connector Debug: {err:?}");
// 2. Through the legacy Client.
let client: Client<HttpConnector, Empty<Bytes>> =
Client::builder(TokioExecutor::new()).build_http();
let err = client.get(uri).await.unwrap_err();
println!("client Display: {err}");
println!("client is_connect: {}", err.is_connect());
let mut src = err.source();
while let Some(e) = src {
println!(" caused by: {e}");
src = e.source();
}
println!("client Debug: {err:?}");
}
M2 Max、rustc 1.99.0,连跑 3 次输出相同:
connector Display: invalid URL, scheme is not http
connector Debug: ConnectError("invalid URL, scheme is not http")
client Display: client error (Connect)
client is_connect: true
caused by: invalid URL, scheme is not http
client Debug: hyper_util::client::legacy::Error(Connect, ConnectError("invalid URL, scheme is not http"))
Client 的错误只显示 client error (Connect),具体原因在 source() 里。日志里只打印 {} 的话只会看到前一半,这是 legacy Client 错误的通病,见 hyper::Error。
问: 既然 HttpConnector 默认拒绝 https,那 reqwest 这些库是怎么连 HTTPS 的?把 enforce_http 关掉就行了吗?
答: 关掉它,HttpConnector 会老老实实连上 443 端口,交回一条明文 TCP;Client 接着在上面写明文 HTTP,对端是 TLS 服务器,只会得到握手失败或者乱码。enforce_http 默认开,就是为了让「忘了配 TLS」在第一步就报错。TLS 连接器是包在外面的另一层 Service<Uri>:hyper-tls 0.6.0 的 HttpsConnector::new 和 hyper-rustls 0.27.10 的 builder 都先构造一个 HttpConnector、调 enforce_http(false),在它返回的 TCP 上为 https 地址做 TLS 握手(hyper-tls 对 http 地址原样透传)。reqwest 0.13.5 的连接器也调了 enforce_http(false)。这样 HttpConnector 只管 TCP,TLS 库可以换,Client 不用知道有没有 TLS(ALPN 结果通过 Connected::negotiated_h2 告诉它)。
第二步:DNS
host 已经是 IP 字面量时,SocketAddrs::try_parse 直接得到一个地址,不走 resolver。否则调 resolver。resolver 是另一个 Service:Service<Name>,返回一个 SocketAddr 迭代器(Resolve 是它的「trait 别名」)。HttpConnector::new() 用默认的 GaiResolver,自定义 resolver 用 new_with_resolver。
fn call(&mut self, name: Name) -> Self::Future {
let blocking = tokio::task::spawn_blocking(move || {
(&*name.host, 0)
.to_socket_addrs()
.map(|i| SocketAddrs { iter: i })
});
GaiFuture { inner: blocking }
}
to_socket_addrs 底下是系统的 getaddrinfo,它是阻塞调用,所以放进 tokio 的阻塞线程池。端口传 0,回来以后由 set_port 换成 URI 的端口(URI 写了端口时总是覆盖;没写时只覆盖 resolver 给的 0,自定义 resolver 返回的非 0 端口会保留)。GaiFuture 在 drop 时 abort 这个 JoinHandle,但已经在执行的阻塞闭包是取消不了的(tokio 卷那一页有实验),一次卡住的 getaddrinfo 会一直占着一个阻塞线程直到系统返回。
本机 localhost 解析出来是什么顺序:
use std::time::Duration;
use hyper::Uri;
use hyper_util::client::legacy::connect::{dns::{GaiResolver, Name}, HttpConnector};
use tower::ServiceExt;
#[tokio::main]
async fn main() {
let addrs: Vec<_> = GaiResolver::new().oneshot("localhost".parse::<Name>().unwrap()).await.unwrap().collect();
println!("localhost -> {addrs:?}");
let mut http = HttpConnector::new();
http.set_connect_timeout(Some(Duration::from_millis(200)));
let err = http.oneshot("http://127.0.0.2:9/".parse::<Uri>().unwrap()).await.unwrap_err();
println!("{err:?}");
}
连跑 3 次输出相同:
localhost -> [[::1]:0, 127.0.0.1:0]
ConnectError("tcp connect error", 127.0.0.2:9, Custom { kind: TimedOut, error: Elapsed(()) })
第一行:macOS 上 localhost 先给 IPv6。第二行留到 connect_timeout 一节解释。
第三步:Happy Eyeballs
拿到地址列表后,ConnectingTcp::new 用 split_by_preference 把它分成首选和备用两组:
_ => {
let preferring_v6 = self
.iter
.as_slice()
.first()
.map(SocketAddr::is_ipv6)
.unwrap_or(false);
let (preferred, fallback) = self
.iter
.partition::<Vec<_>, _>(|addr| addr.is_ipv6() == preferring_v6);
(SocketAddrs::new(preferred), SocketAddrs::new(fallback))
}
首选的是列表第一个地址的协议族,不一定是 IPv6。resolver 先给 IPv6 就先连 IPv6,先给 IPv4 就先连 IPv4,组内保持 resolver 给的顺序。只设了 IPv4 或只设了 IPv6 本地地址(set_local_address)时,前面两个分支只留那一族,备用组为空。
备用组为空,或者 happy_eyeballs_timeout 设成 None,就没有备用:所有地址放进一组,逐个顺序连。否则备用组带一个 tokio::time::sleep(fallback_timeout),默认 300ms(set_happy_eyeballs_timeout)。这个 Sleep 在 new 里就创建了,计时从解析完成、开始连接时算起。
hyper-util 0.1.13 的 #194 把上面这段改成了 self.iter.partition::<Vec<_>, _>(SocketAddr::is_ipv6),注释写「Happy Eyeballs says we always give a preference to v6 if available」,不管 resolver 怎么排,都先连 IPv6。CHANGELOG 记作「Fix HttpConnector to always prefer IPv6 addresses first, if happy eyeballs is enabled.」
两周后有人报告 hyper#3900「hyper-util 0.1.13 breaks reqwest for networks where IPv6 returns a connection reset」:用户的网络上 IPv6 连接会被重置,升级到 0.1.13 后 uv 就连不上 PyPI 了,退回 0.1.12 就好。0.1.14 的 #200 把代码改回按第一个地址决定,PR 的说法是「This was changed in #194, but that change seems to be wrong. The resolver is allowed to express it's priority through the ordering.」并引了 RFC 6555 第 6 节。CHANGELOG:「Fix HttpConnector to defer address family order to resolver sort order.」test_ip_addrs_split_by_preference 里 [v4, v6] 得到「首选 IPv4」的断言,也跟着 #194 改过去、又跟着 #200 改了回来。
两组怎么赛跑在 ConnectingTcp::connect:
async fn connect(mut self) -> Result<TcpStream, ConnectError> {
match self.fallback {
None => self.preferred.connect(self.config).await,
Some(mut fallback) => {
let preferred_fut = pin!(self.preferred.connect(self.config));
let fallback_fut = pin!(fallback.remote.connect(self.config));
let fallback_delay = pin!(fallback.delay);
let (result, future) =
match futures_util::future::select(preferred_fut, fallback_delay).await {
Either::Left((result, _fallback_delay)) => {
(result, Either::Right(fallback_fut))
}
Either::Right(((), preferred_fut)) => {
// Delay is done, start polling both the preferred and the fallback
futures_util::future::select(preferred_fut, fallback_fut)
.await
.factor_first()
}
};
if result.is_err() {
// Fallback to the remaining future (could be preferred or fallback)
// if we get an error
future.await
} else {
result
}
}
}
}
- 首选组在延迟到期前有了结果:成功就用;失败就转去 await 备用组。备用组的 future 之前从没被 poll 过,这时才开始连,不再等剩下的延迟。
- 延迟先到期:首选和备用一起跑,谁先有结果算谁;先到的是失败,就再等另一个。
每组内部由 ConnectingTcpRemote::connect 逐个地址顺序尝试,全失败时返回第一个错误(注释:「only return the first error, we assume it's the most relevant」),错误里带上那个地址。
下面不碰外部网络,用一个忽略域名、按固定顺序返回地址的 resolver 来造场景。需要一个「连了没回应」的地址:macOS 上 127.0.0.2 走 lo0,但没有分配给任何接口,SYN 发出去没有回应,连接会一直挂着(上一节的 127.0.0.2:9 就是这样超时的)。这个行为是 macOS 的,别的系统上未必如此。
hyper = "=1.12.0"
hyper-util = { version = "=0.1.21", features = ["client-legacy", "tokio"] }
tokio = { version = "=1.54.1", features = ["full"] }
tower = { version = "=0.5.3", features = ["util"] }
use std::{convert::Infallible, error::Error, net::SocketAddr, time::{Duration, Instant}};
use hyper::Uri;
use hyper_util::client::legacy::connect::{dns::Name, HttpConnector};
use tokio::net::TcpListener;
use tower::{service_fn, ServiceExt};
// 127.0.0.2 is on loopback but not assigned on macOS: SYNs vanish, connect hangs.
async fn run(label: &str, addrs: Vec<SocketAddr>, port: u16, eyeballs: Option<Duration>, timeout: Option<Duration>) {
// A resolver that ignores the name and returns a fixed list, in order.
let resolver = service_fn(move |_: Name| {
let addrs = addrs.clone();
async move { Ok::<_, Infallible>(addrs.into_iter()) }
});
let mut http = HttpConnector::new_with_resolver(resolver);
http.set_happy_eyeballs_timeout(eyeballs);
http.set_connect_timeout(timeout);
let uri: Uri = format!("http://dual.test:{port}/").parse().unwrap();
let start = Instant::now();
match http.oneshot(uri).await {
Ok(io) => println!("{label}: connected to {} after {}ms", io.inner().peer_addr().unwrap(), start.elapsed().as_millis()),
Err(e) => println!("{label}: {e} ({}) after {}ms", e.source().unwrap(), start.elapsed().as_millis()),
}
}
#[tokio::main]
async fn main() {
let v4 = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = v4.local_addr().unwrap().port();
let _v6 = TcpListener::bind(("::1", port)).await.unwrap();
let black: SocketAddr = ([127, 0, 0, 2], port).into();
let live4: SocketAddr = ([127, 0, 0, 1], port).into();
let live6: SocketAddr = format!("[::1]:{port}").parse().unwrap();
let ms = Duration::from_millis;
run("A [black4, live6], default", vec![black, live6], port, Some(ms(300)), None).await;
run("B [black4, live6], eyeballs off, timeout 1s", vec![black, live6], port, None, Some(ms(1000))).await;
run("C [black4, live4], timeout 1s", vec![black, live4], port, Some(ms(300)), Some(ms(1000))).await;
run("D [black4], timeout 200ms", vec![black], port, Some(ms(300)), Some(ms(200))).await;
}
M2 Max、rustc 1.99.0,连跑 3 次,连上的地址相同,毫秒数差 9ms 以内:
A [black4, live6], default: connected to [::1]:64833 after 303ms
B [black4, live6], eyeballs off, timeout 1s: connected to [::1]:64833 after 503ms
C [black4, live4], timeout 1s: connected to 127.0.0.1:64833 after 503ms
D [black4], timeout 200ms: tcp connect error (deadline has elapsed) after 203ms
- A:第一个地址是 IPv4,所以首选组是
[127.0.0.2],备用组是[::1]。首选挂住,300ms 后备用组开跑,立刻连上::1。这就是 Happy Eyeballs 的用处:一个协议族不通时,代价是 300ms 而不是一次系统级的连接超时。 - B、C、D 讲的是
connect_timeout,见下一节。
hyper-util 自己的测试 client_happy_eyeballs 覆盖了更多组合:首选快、首选不可达(立即失败)、首选慢(等延迟后由备用赢)、备用里先不可达再成功,每种都断言连上的协议族和耗时(允许 ±150ms)。它用的「慢」地址是 RFC 6890 保留的 198.18.0.25 和 2001:2::fe,要经过真实网络才能表现出「慢」,所以这个测试标着 #[ignore] // TODO,还要打开内部 feature __internal_happy_eyeballs_tests 才会编译运行。本页没有运行它。
connect_timeout:按地址数平分
set_connect_timeout 默认 None,此时一次连接要多久由操作系统决定。设了以后,ConnectingTcpRemote::new 把它平分给组内每个地址:
fn new(addrs: dns::SocketAddrs, connect_timeout: Option<Duration>) -> Self {
let connect_timeout = connect_timeout.and_then(|t| t.checked_div(addrs.len() as u32));
Self {
addrs,
connect_timeout,
}
}
checked_div 在地址数为 0 时返回 None,避免除零 panic:这是从 hyper 0.14 移植过来的修复(#76,hyper-util 0.1.2),PR 标题是「divide by zero error when dividing duration by scalar」。
每个地址的连接在 connect 函数末尾套一层 tokio::time::timeout,超时被转成 io::ErrorKind::TimedOut,再包成 ConnectError("tcp connect error"):
let connect = socket.connect(*addr);
Ok(async move {
match connect_timeout {
Some(dur) => match tokio::time::timeout(dur, connect).await {
Ok(Ok(s)) => Ok(s),
Ok(Err(e)) => Err(e),
Err(e) => Err(io::Error::new(io::ErrorKind::TimedOut, e)),
},
None => connect.await,
}
.map_err(ConnectError::m("tcp connect error"))
})
对照上面的输出:
- D:只有一个挂住的地址,超时 200ms,203ms 报错。
Display只有tcp connect error,source()是 tokio 的Elapsed(「deadline has elapsed」)。上一节{:?}的输出里还能看到地址:ConnectError("tcp connect error", 127.0.0.2:9, Custom { kind: TimedOut, error: Elapsed(()) })。 - C:两个地址都是 IPv4,没有备用组,1s 被平分成每个 500ms。
127.0.0.2500ms 后超时,换127.0.0.1,503ms 连上。 - B:和 A 同样的地址,但关掉 Happy Eyeballs:两族合成一组顺序连,1s 平分成 500ms,
::1在 503ms 才连上,比 A 慢了 200ms。
平分是按组算的:开着 Happy Eyeballs 时首选组和备用组各自除以自己的地址数。平分的结果是「一个挂住的地址最多耽误 timeout / n」,代价是排在后面的正常地址也只有 timeout / n 可用,地址很多而超时很短时,慢一点的正常地址可能被误判超时。
其他 socket 选项
都在 connect 里、真正发起连接之前设到 socket 上(nodelay 例外,连上之后在 call_async 里设):
| 选项 | 默认 | 说明 |
|---|---|---|
set_nodelay | false | TCP_NODELAY |
set_keepalive / _interval / _retries | 都是 None(不开) | TCP keepalive;legacy Builder::build_http 在池子开着时把时间设成 pool_idle_timeout,默认 90 秒 |
set_reuse_address | false | SO_REUSEADDR |
set_send_buffer_size / set_recv_buffer_size | None | SO_SNDBUF / SO_RCVBUF |
set_local_address / set_local_addresses | None | 绑定本地地址;只给一族时 Happy Eyeballs 只连那一族 |
set_interface | None | Linux 系用 SO_BINDTODEVICE,macOS 系用 IP_BOUND_IF |
set_mark、set_tcp_user_timeout | None | 只在 Linux、Android、Fuchsia 上有 |
设置失败的处理分两种:keepalive、nodelay、缓冲区大小、reuse_address 失败只打一条 warn!,照常连接;创建 socket、设非阻塞、绑定接口或本地地址失败,直接返回对应的 ConnectError(tcp open error、tcp bind local error 等),这一组剩下的地址也不再尝试。
只在 127.0.0.1 上监听,::1 的同一端口没人监听(连过去会立刻被拒绝)。resolver 先给 ::1 再给 127.0.0.1,Happy Eyeballs 用默认的 300ms。上面的 run 函数不变,main 换成:
#[tokio::main]
async fn main() {
// Something listens on 127.0.0.1:port, nothing on [::1]:port.
let v4 = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = v4.local_addr().unwrap().port();
let addrs = vec![format!("[::1]:{port}").parse().unwrap(), ([127, 0, 0, 1], port).into()];
run("[::1, 127.0.0.1], default", addrs, port, Some(Duration::from_millis(300)), None).await;
}
最后连上哪个地址?用了大约多少毫秒?答案在本页末尾。这也是 http://localhost:端口 打到一个只监听 127.0.0.1 的服务时,在 macOS 上走的路径(localhost 先解析出 ::1)。
动脑筋答案
M2 Max、rustc 1.99.0,连跑 3 次,都是 0ms:
[::1, 127.0.0.1], default: connected to 127.0.0.1:64917 after 0ms
首选组是 [::1]。连接被拒绝是立即返回的错误,select(preferred_fut, fallback_delay) 里首选组先完成,结果是 Err,于是 future.await 直接去跑备用组 [127.0.0.1],不等 300ms 的延迟。300ms 只在首选组「既不成功也不失败」时才付出,比如上面实验 A 里挂住的 127.0.0.2。
小结
- 连接器就是
Service<Uri>,返回的 IO 要实现 hyper 的Read + Write和Connection;Connected把 ALPN、代理、HttpInfo、poison 标记告诉Client。TLS 连接器包在HttpConnector外面,并把它的enforce_http关掉。 enforce_http默认开:非http的 URI 在解析之前就以invalid URL, scheme is not http失败,经Client后显示为client error (Connect),原因在source()里。- IP 字面量不走 DNS;默认
GaiResolver在 tokio 阻塞线程池里调getaddrinfo,端口事后补上。 - Happy Eyeballs 首选的是 resolver 返回的第一个地址的协议族(0.1.13 曾强制 IPv6 优先,0.1.14 改回);首选组失败就立刻转备用组,挂住才等
happy_eyeballs_timeout(默认 300ms)。 connect_timeout默认不设,设了以后在每组内按地址数平分;超时报tcp connect error,原因是deadline has elapsed。