Skip to main content

polyoxide_ws/
transport.rs

1//! How a connection is dialled: the socket transport seam, and the native
2//! transport behind it.
3
4#[cfg(not(target_arch = "wasm32"))]
5use std::net::{IpAddr, SocketAddr};
6use std::{fmt, sync::Arc, time::Duration};
7
8use futures_util::{Sink, Stream};
9use polyoxide_rt::{BoxFuture, Runtime};
10#[cfg(not(target_arch = "wasm32"))]
11use tokio::net::TcpStream;
12
13use crate::{
14    tungstenite::{self, http::Request, Message},
15    WsError,
16};
17#[cfg(not(target_arch = "wasm32"))]
18use crate::{
19    tungstenite::{error::UrlError, http::Uri},
20    Kind,
21};
22
23/// An open connection as the kit reads and writes it: a stream of frames and a
24/// sink for them, both failing with tungstenite's error.
25///
26/// Every type that is both, and `Send + Unpin`, is one, so a transport returns
27/// its own socket boxed as a [`BoxSocket`].
28pub trait SocketIo:
29    Stream<Item = Result<Message, tungstenite::Error>>
30    + Sink<Message, Error = tungstenite::Error>
31    + Send
32    + Unpin
33{
34}
35
36impl<T> SocketIo for T where
37    T: Stream<Item = Result<Message, tungstenite::Error>>
38        + Sink<Message, Error = tungstenite::Error>
39        + Send
40        + Unpin
41{
42}
43
44/// An open connection, boxed: what a [`SocketTransport`] returns and
45/// [`Connection`](crate::Connection) reads.
46pub type BoxSocket = Box<dyn SocketIo>;
47
48/// How a WebSocket connection is dialled.
49///
50/// A builder holds one, or leaves it to [`default_socket_transport`], so a
51/// consumer injects nothing unless it wants its own dial. The transport
52/// bounds each of its own steps with the `runtime` it is given, so one
53/// transport serves clients with different runtimes, and a connect that does
54/// not open within `timeout` fails as [`Kind::ConnectTimeout`](crate::Kind::ConnectTimeout).
55/// Every other failure is the transport's: a refused upgrade is a
56/// [`Kind::Refused`](crate::Kind::Refused) where the transport can see its
57/// status.
58pub trait SocketTransport: Send + Sync + fmt::Debug + 'static {
59    /// Open the connection `request` describes, within `timeout`.
60    fn connect(
61        &self,
62        request: Request<()>,
63        timeout: Duration,
64        runtime: Arc<dyn Runtime>,
65    ) -> BoxFuture<Result<BoxSocket, WsError>>;
66
67    /// What this transport can do, so a protocol adapts instead of failing.
68    fn capabilities(&self) -> Capabilities;
69}
70
71/// What a [`SocketTransport`] can do.
72///
73/// A protocol reads it to choose how it keeps a connection alive: a transport
74/// that cannot send a ping frame gets an application-level ping, and one that
75/// never shows the server's ping frames cannot count them as liveness.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77#[non_exhaustive]
78pub struct Capabilities {
79    /// Whether the client can send ping and pong frames.
80    pub client_pings: bool,
81    /// Whether the server's ping frames reach the reader as messages.
82    pub sees_server_pings: bool,
83    /// Whether the handshake request may carry headers beyond the ones the
84    /// WebSocket protocol generates.
85    ///
86    /// Informational: nothing in the tree reads it. A transport without it
87    /// refuses a handshake header with `Kind::Unsupported` when it dials.
88    pub handshake_headers: bool,
89}
90
91impl Capabilities {
92    /// Every capability: what a native socket has.
93    pub const FULL: Capabilities = Capabilities::new(true, true, true);
94
95    /// A transport's capabilities.
96    pub const fn new(client_pings: bool, sees_server_pings: bool, handshake_headers: bool) -> Self {
97        Self {
98            client_pings,
99            sees_server_pings,
100            handshake_headers,
101        }
102    }
103}
104
105/// The transport a socket uses when none is injected: `TungsteniteTransport`
106/// on a host target and `WebSocketTransport` on `wasm32`.
107#[cfg(not(target_arch = "wasm32"))]
108pub fn default_socket_transport() -> Arc<dyn SocketTransport> {
109    Arc::new(TungsteniteTransport::new())
110}
111
112/// The transport a socket uses when none is injected: `TungsteniteTransport`
113/// on a host target and `WebSocketTransport` on `wasm32`.
114#[cfg(target_arch = "wasm32")]
115pub fn default_socket_transport() -> Arc<dyn SocketTransport> {
116    Arc::new(crate::WebSocketTransport::new())
117}
118
119/// The native transport: tokio-tungstenite over TCP, with TLS from rustls.
120///
121/// It installs the rustls `CryptoProvider` before it dials
122/// ([`ensure_crypto_provider`](crate::ensure_crypto_provider)), since a
123/// consumer that builds two rustls backends gets no default and a direct
124/// connect would panic. Its capabilities are all `true`.
125#[cfg(not(target_arch = "wasm32"))]
126#[derive(Debug, Clone, Copy, Default)]
127pub struct TungsteniteTransport {
128    per_address: bool,
129}
130
131#[cfg(not(target_arch = "wasm32"))]
132impl TungsteniteTransport {
133    /// The plain dial: tungstenite's own connect, which resolves the host and
134    /// connects to the first address it returns.
135    pub const fn new() -> Self {
136        Self { per_address: false }
137    }
138
139    /// The dial of a host that has several addresses, some of which may not
140    /// answer: it resolves every address up front, tries each in
141    /// family-interleaved order (IPv6 and IPv4 alternating, RFC 8305 section 4),
142    /// and bounds the TCP connect and, separately, the TLS and WebSocket
143    /// upgrade of each by the connect timeout, so a blackholed route costs one
144    /// timeout and not one per address. It falls back to the next address on
145    /// any failure but a refused upgrade, which it returns at once: another
146    /// address would repeat the refusal, and its own failure would hide this
147    /// one's status and `Retry-After`.
148    pub const fn per_address() -> Self {
149        Self { per_address: true }
150    }
151}
152
153#[cfg(not(target_arch = "wasm32"))]
154impl SocketTransport for TungsteniteTransport {
155    fn connect(
156        &self,
157        request: Request<()>,
158        timeout: Duration,
159        runtime: Arc<dyn Runtime>,
160    ) -> BoxFuture<Result<BoxSocket, WsError>> {
161        let per_address = self.per_address;
162        Box::pin(async move {
163            crate::ensure_crypto_provider();
164            if per_address {
165                return dial_per_address(request, timeout, runtime.as_ref()).await;
166            }
167            let (socket, _response) = crate::connect::bounded(
168                runtime.as_ref(),
169                timeout,
170                tokio_tungstenite::connect_async(request),
171            )
172            .await??;
173            Ok::<BoxSocket, WsError>(Box::new(socket))
174        })
175    }
176
177    fn capabilities(&self) -> Capabilities {
178        Capabilities::FULL
179    }
180}
181
182/// Interleave IPv6 and IPv4 addresses, Happy-Eyeballs style (RFC 8305 ยง4).
183///
184/// A lookup typically returns all AAAA records before all A records, so a
185/// network with broken IPv6 would burn one full timeout per v6 address before
186/// ever trying v4. Alternating families bounds the damage to a single timeout.
187#[cfg(not(target_arch = "wasm32"))]
188fn interleave_address_families(addrs: Vec<SocketAddr>) -> Vec<SocketAddr> {
189    let (v6, v4): (Vec<_>, Vec<_>) = addrs.into_iter().partition(SocketAddr::is_ipv6);
190    let mut out = Vec::with_capacity(v6.len() + v4.len());
191    let (mut v6, mut v4) = (v6.into_iter(), v4.into_iter());
192    loop {
193        match (v6.next(), v4.next()) {
194            (None, None) => return out,
195            (a, b) => out.extend(a.into_iter().chain(b)),
196        }
197    }
198}
199
200/// An I/O failure while connecting, as the kit reports a transport's.
201#[cfg(not(target_arch = "wasm32"))]
202fn io_error(err: std::io::Error) -> WsError {
203    WsError::from(tungstenite::Error::Io(err))
204}
205
206/// A host that resolved to no address: a DNS miss, so an I/O failure like any
207/// other, not a refusal of the request.
208#[cfg(not(target_arch = "wasm32"))]
209fn no_addresses(uri: &Uri) -> WsError {
210    io_error(std::io::Error::new(
211        std::io::ErrorKind::NotFound,
212        format!("no addresses resolved for {uri}"),
213    ))
214}
215
216/// What a request names to dial: an address as written, or a name to resolve.
217#[cfg(not(target_arch = "wasm32"))]
218#[derive(Debug, PartialEq, Eq)]
219enum Target<'a> {
220    Ip(IpAddr),
221    Name(&'a str),
222}
223
224/// The host a URI names and the port to dial: the URI's own, or its scheme's
225/// default (80 for `ws://`, 443 otherwise). `None` when it has no host.
226#[cfg(not(target_arch = "wasm32"))]
227fn dial_target(uri: &Uri) -> Option<(Target<'_>, u16)> {
228    let host = uri.host()?;
229    let port = uri.port_u16().unwrap_or(match uri.scheme_str() {
230        Some("ws") => 80,
231        _ => 443,
232    });
233    let literal = host.trim_start_matches('[').trim_end_matches(']');
234    Some(match literal.parse::<IpAddr>() {
235        Ok(ip) => (Target::Ip(ip), port),
236        Err(_) => (Target::Name(host), port),
237    })
238}
239
240/// Open `request` address by address, within `timeout` for each step.
241#[cfg(not(target_arch = "wasm32"))]
242async fn dial_per_address(
243    request: Request<()>,
244    timeout: Duration,
245    runtime: &dyn Runtime,
246) -> Result<BoxSocket, WsError> {
247    let uri = request.uri().clone();
248    let (target, port) = dial_target(&uri)
249        .ok_or_else(|| WsError::from(tungstenite::Error::Url(UrlError::NoHostName)))?;
250
251    // An address as written needs no lookup; only a name is resolved.
252    let addrs: Vec<SocketAddr> = match target {
253        Target::Ip(ip) => vec![SocketAddr::new(ip, port)],
254        Target::Name(name) => tokio::net::lookup_host((name, port))
255            .await
256            .map_err(io_error)?
257            .collect(),
258    };
259
260    dial_addresses(request, addrs, timeout, runtime).await
261}
262
263/// Open `request` on each of `addrs` in family-interleaved order, within
264/// `timeout` for each step, and return the first upgraded socket. A refused
265/// upgrade is returned at once; any other failure falls through to the next
266/// address, and the last one is returned when none answers.
267#[cfg(not(target_arch = "wasm32"))]
268async fn dial_addresses(
269    request: Request<()>,
270    addrs: Vec<SocketAddr>,
271    timeout: Duration,
272    runtime: &dyn Runtime,
273) -> Result<BoxSocket, WsError> {
274    let mut last_err = no_addresses(request.uri());
275    for addr in interleave_address_families(addrs) {
276        let stream = match crate::connect::bounded(runtime, timeout, TcpStream::connect(addr)).await
277        {
278            Ok(Ok(stream)) => stream,
279            Ok(Err(err)) => {
280                last_err = io_error(err);
281                continue;
282            }
283            Err(err) => {
284                last_err = err;
285                continue;
286            }
287        };
288        // TCP is up; bound the TLS and WebSocket handshake by the same budget.
289        match crate::connect::bounded(
290            runtime,
291            timeout,
292            crate::connect_over(request.clone(), stream),
293        )
294        .await
295        {
296            Ok(Ok(socket)) => return Ok(socket),
297            // The host answered and refused the upgrade.
298            Ok(Err(err)) if matches!(err.kind(), Kind::Refused { .. }) => return Err(err),
299            Ok(Err(err)) => last_err = err,
300            Err(err) => last_err = err,
301        }
302    }
303    Err(last_err)
304}
305
306/// Generates the transport setters a socket builder offers, each writing a
307/// field of the builder: `runtime(Arc<dyn Runtime>)` writes `self.runtime`, an
308/// `Option<Arc<dyn polyoxide_rt::Runtime>>`, and `socket_transport(Arc<dyn
309/// SocketTransport>)` writes `self.socket_transport`, an
310/// `Option<Arc<dyn SocketTransport>>`.
311///
312/// Invoke it in the builder's `impl` as [`timing_setters!`](crate::timing_setters)
313/// is, listing the setters wanted, each with its own doc comment. The
314/// invoking crate needs no `polyoxide-rt` dependency for it. A builder's connect falls back to
315/// [`default_runtime`](polyoxide_rt::default_runtime) and its default
316/// transport for a field left `None`.
317///
318/// ```
319/// use std::sync::Arc;
320///
321/// #[derive(Default)]
322/// pub struct FeedBuilder {
323///     runtime: Option<Arc<dyn polyoxide_rt::Runtime>>,
324///     socket_transport: Option<Arc<dyn polyoxide_ws::SocketTransport>>,
325/// }
326///
327/// impl FeedBuilder {
328///     polyoxide_ws::transport_setters! {
329///         /// The runtime the feed's timers and tasks run on.
330///         runtime;
331///         /// How the feed dials.
332///         socket_transport;
333///     }
334/// }
335///
336/// let builder = FeedBuilder::default()
337///     .runtime(polyoxide_rt::default_runtime())
338///     .socket_transport(polyoxide_ws::default_socket_transport());
339/// assert!(builder.runtime.is_some() && builder.socket_transport.is_some());
340/// ```
341#[macro_export]
342macro_rules! transport_setters {
343    () => {};
344    ($(#[$meta:meta])* runtime; $($rest:tt)*) => {
345        $(#[$meta])*
346        pub fn runtime(
347            mut self,
348            runtime: ::std::sync::Arc<dyn $crate::__rt::Runtime>,
349        ) -> Self {
350            self.runtime = ::core::option::Option::Some(runtime);
351            self
352        }
353        $crate::transport_setters! { $($rest)* }
354    };
355    ($(#[$meta:meta])* socket_transport; $($rest:tt)*) => {
356        $(#[$meta])*
357        pub fn socket_transport(
358            mut self,
359            transport: ::std::sync::Arc<dyn $crate::SocketTransport>,
360        ) -> Self {
361            self.socket_transport = ::core::option::Option::Some(transport);
362            self
363        }
364        $crate::transport_setters! { $($rest)* }
365    };
366}
367
368#[cfg(test)]
369mod tests {
370    use std::{sync::Arc, time::Duration};
371
372    use futures_util::StreamExt;
373    use polyoxide_rt::{default_runtime, BoxFuture, Instant, Runtime};
374
375    use super::*;
376    use crate::{
377        test_server::{Script, ScriptedServer},
378        tungstenite::{client::IntoClientRequest, Message},
379        Kind,
380    };
381
382    /// A runtime whose clock has already run out: every sleep is over at once.
383    #[derive(Debug)]
384    struct Elapsed;
385
386    impl Runtime for Elapsed {
387        fn spawn(&self, _task: BoxFuture<()>) {}
388
389        fn sleep(&self, _duration: Duration) -> BoxFuture<()> {
390            Box::pin(std::future::ready(()))
391        }
392
393        fn now(&self) -> Instant {
394            Instant::now()
395        }
396    }
397
398    #[test]
399    fn capabilities_are_what_they_were_built_with() {
400        let caps = Capabilities::new(false, true, false);
401        assert!(!caps.client_pings);
402        assert!(caps.sees_server_pings);
403        assert!(!caps.handshake_headers);
404        assert_eq!(Capabilities::FULL, Capabilities::new(true, true, true));
405        assert_ne!(Capabilities::FULL, caps);
406    }
407
408    #[test]
409    fn the_native_default_transport_can_do_everything() {
410        assert_eq!(
411            default_socket_transport().capabilities(),
412            Capabilities::FULL
413        );
414        assert_eq!(
415            TungsteniteTransport::new().capabilities(),
416            Capabilities::FULL
417        );
418    }
419
420    #[tokio::test]
421    async fn the_plain_transport_dials_and_the_socket_reads_a_frame() {
422        let server = ScriptedServer::start(vec![Script::frames(&["hello"])]).await;
423        let request = server.url.as_str().into_client_request().unwrap();
424        let mut socket = TungsteniteTransport::new()
425            .connect(request, Duration::from_secs(2), default_runtime())
426            .await
427            .expect("connect");
428        let first = tokio::time::timeout(Duration::from_secs(3), socket.next())
429            .await
430            .expect("a frame within the window")
431            .expect("the stream has not ended")
432            .expect("a frame, not an error");
433        assert_eq!(first, Message::Text("hello".into()));
434    }
435
436    #[tokio::test]
437    async fn a_refused_upgrade_keeps_its_status() {
438        let server = ScriptedServer::start(vec![Script::refuse_upgrade(503)]).await;
439        let request = server.url.as_str().into_client_request().unwrap();
440        let Err(err) = TungsteniteTransport::new()
441            .connect(request, Duration::from_secs(2), default_runtime())
442            .await
443        else {
444            panic!("a refused upgrade must not connect");
445        };
446        assert_eq!(
447            err.kind(),
448            Kind::Refused {
449                status: 503,
450                retry_after: None
451            }
452        );
453    }
454
455    #[tokio::test]
456    async fn the_transport_bounds_its_connect_with_the_runtime_it_is_given() {
457        // The server never answers the upgrade and the timeout is an hour, so
458        // only the given runtime's timer, which is already over, can end it.
459        let server = ScriptedServer::start(vec![Script::stall()]).await;
460        let request = server.url.as_str().into_client_request().unwrap();
461        let after = Duration::from_secs(3600);
462        let Err(err) = TungsteniteTransport::new()
463            .connect(request, after, Arc::new(Elapsed))
464            .await
465        else {
466            panic!("a stalled handshake must not connect");
467        };
468        assert_eq!(err.kind(), Kind::ConnectTimeout { after });
469    }
470
471    #[test]
472    fn the_setters_write_the_builders_fields() {
473        #[derive(Default)]
474        struct Builder {
475            runtime: Option<Arc<dyn Runtime>>,
476            socket_transport: Option<Arc<dyn SocketTransport>>,
477        }
478
479        impl Builder {
480            crate::transport_setters! {
481                /// The runtime.
482                runtime;
483                /// The transport.
484                socket_transport;
485            }
486        }
487
488        let bare = Builder::default();
489        assert!(bare.runtime.is_none() && bare.socket_transport.is_none());
490        let set = Builder::default()
491            .runtime(default_runtime())
492            .socket_transport(default_socket_transport());
493        assert!(set.runtime.is_some() && set.socket_transport.is_some());
494    }
495}
496
497#[cfg(all(test, not(target_arch = "wasm32")))]
498mod connect_tests {
499    use std::{net::SocketAddr, time::Duration};
500
501    use futures_util::StreamExt;
502    use polyoxide_rt::default_runtime;
503
504    use super::*;
505    use crate::{
506        test_server::{Script, ScriptedServer},
507        tungstenite::client::IntoClientRequest,
508        Kind,
509    };
510
511    fn addr(s: &str) -> SocketAddr {
512        s.parse().unwrap()
513    }
514
515    #[test]
516    fn address_families_are_interleaved() {
517        // Resolvers return all AAAA then all A; a broken v6 network must not
518        // pay one timeout per v6 address before the first v4 attempt.
519        let addrs = vec![
520            addr("[2001:db8::1]:443"),
521            addr("[2001:db8::2]:443"),
522            addr("192.0.2.1:443"),
523            addr("192.0.2.2:443"),
524            addr("192.0.2.3:443"),
525        ];
526        let out = interleave_address_families(addrs);
527        assert_eq!(
528            out,
529            vec![
530                addr("[2001:db8::1]:443"),
531                addr("192.0.2.1:443"),
532                addr("[2001:db8::2]:443"),
533                addr("192.0.2.2:443"),
534                addr("192.0.2.3:443"),
535            ]
536        );
537    }
538
539    #[test]
540    fn single_family_lists_pass_through_unchanged() {
541        let v4 = vec![addr("192.0.2.1:443"), addr("192.0.2.2:443")];
542        assert_eq!(interleave_address_families(v4.clone()), v4);
543        assert!(interleave_address_families(Vec::new()).is_empty());
544    }
545
546    #[test]
547    fn a_uri_without_a_port_dials_its_schemes_default() {
548        for (uri, port) in [
549            ("ws://127.0.0.1/", 80),
550            ("wss://127.0.0.1/", 443),
551            ("ws://127.0.0.1:9/", 9),
552            ("wss://example.com/ws/market", 443),
553        ] {
554            let parsed: Uri = uri.parse().unwrap();
555            let (_, dialled) = dial_target(&parsed).expect(uri);
556            assert_eq!(dialled, port, "{uri}");
557        }
558        assert_eq!(
559            dial_target(&"ws://[::1]/".parse().unwrap()),
560            Some((Target::Ip(std::net::Ipv6Addr::LOCALHOST.into()), 80))
561        );
562        assert_eq!(
563            dial_target(&"wss://example.com/x".parse().unwrap()),
564            Some((Target::Name("example.com"), 443))
565        );
566    }
567
568    #[test]
569    fn a_dns_miss_is_an_io_failure_and_says_what_it_could_not_resolve() {
570        let err = no_addresses(
571            &"wss://ws.example.invalid/feed"
572                .parse()
573                .expect("a valid uri"),
574        );
575        assert_eq!(err.kind(), Kind::Io);
576        assert_eq!(
577            err.to_string(),
578            "IO error: no addresses resolved for wss://ws.example.invalid/feed"
579        );
580    }
581
582    #[tokio::test]
583    async fn per_address_returns_the_upgraded_socket_with_nothing_read() {
584        let server = ScriptedServer::start(vec![Script::frames(&["hello"])]).await;
585        let request = server.url.as_str().into_client_request().unwrap();
586        let mut socket = TungsteniteTransport::per_address()
587            .connect(request, Duration::from_secs(2), default_runtime())
588            .await
589            .expect("connect");
590        let first = tokio::time::timeout(Duration::from_secs(3), socket.next())
591            .await
592            .expect("a frame within the window")
593            .expect("the stream has not ended")
594            .expect("a frame, not an error");
595        assert_eq!(first, Message::Text("hello".into()));
596    }
597
598    #[tokio::test]
599    async fn a_refused_upgrade_is_returned_at_once_with_other_addresses_left() {
600        let server = ScriptedServer::start(vec![Script::refuse_upgrade(503)]).await;
601        let request = server.url.as_str().into_client_request().unwrap();
602        let addr: SocketAddr = server
603            .url
604            .trim_start_matches("ws://")
605            .parse()
606            .expect("the server's address");
607        let Err(err) = dial_addresses(
608            request,
609            vec![addr, addr],
610            Duration::from_secs(2),
611            default_runtime().as_ref(),
612        )
613        .await
614        else {
615            panic!("a refused upgrade must not connect");
616        };
617        assert_eq!(
618            err.kind(),
619            Kind::Refused {
620                status: 503,
621                retry_after: None
622            }
623        );
624        assert_eq!(
625            server.connection_count(),
626            1,
627            "the second address is not tried"
628        );
629    }
630
631    #[tokio::test]
632    async fn a_stalled_address_falls_through_to_the_next() {
633        let stalled = ScriptedServer::start(vec![Script::stall()]).await;
634        let serving = ScriptedServer::start(vec![Script::frames(&["hello"])]).await;
635        let address = |server: &ScriptedServer| -> SocketAddr {
636            server
637                .url
638                .trim_start_matches("ws://")
639                .parse()
640                .expect("the server's address")
641        };
642        let request = serving.url.as_str().into_client_request().unwrap();
643        let mut socket = dial_addresses(
644            request,
645            vec![address(&stalled), address(&serving)],
646            Duration::from_millis(200),
647            default_runtime().as_ref(),
648        )
649        .await
650        .expect("the second address answers");
651        let first = tokio::time::timeout(Duration::from_secs(3), socket.next())
652            .await
653            .expect("a frame within the window")
654            .expect("the stream has not ended")
655            .expect("a frame, not an error");
656        assert_eq!(first, Message::Text("hello".into()));
657        assert_eq!(stalled.connection_count(), 1);
658    }
659
660    #[tokio::test]
661    async fn per_address_bounds_the_handshake_by_the_timeout() {
662        let server = ScriptedServer::start(vec![Script::stall()]).await;
663        let request = server.url.as_str().into_client_request().unwrap();
664        let after = Duration::from_millis(200);
665        let Err(err) = TungsteniteTransport::per_address()
666            .connect(request, after, default_runtime())
667            .await
668        else {
669            panic!("a stalled handshake must not connect");
670        };
671        assert_eq!(err.kind(), Kind::ConnectTimeout { after });
672    }
673
674    #[tokio::test]
675    async fn a_request_naming_no_host_is_a_url_failure_before_any_dial() {
676        let request = Request::builder().uri("/feed").body(()).unwrap();
677        let Err(err) = TungsteniteTransport::per_address()
678            .connect(request, Duration::from_secs(2), default_runtime())
679            .await
680        else {
681            panic!("a request with no host must not connect");
682        };
683        assert_eq!(err.kind(), Kind::Url);
684    }
685}