Skip to main content

scion_http3/
client.rs

1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The client: epoch lifecycle and request orchestration.
16
17use std::{
18    sync::{
19        Arc,
20        atomic::{AtomicU64, Ordering},
21    },
22    time::{Duration, Instant},
23};
24
25use bytes::Bytes;
26use scion_quic::h3::client::{
27    H3DuplexStream, H3ResponseBody, Http3Client, RequestError as H3RequestError,
28};
29
30use crate::{
31    authority::Authority,
32    config::Config,
33    epoch::Epoch,
34    error::{Error, TimeoutPhase},
35    origin::Origin,
36    request::{IntoUrl, Request},
37    response::Response,
38    tunnel::{Tunnel, connect_request},
39};
40
41/// The current connectivity, or the reason there is none.
42enum EpochSlot {
43    /// No connectivity has been built yet (construction does no I/O; the
44    /// first request builds it).
45    Init,
46    /// Active connectivity, tagged with the reset generation it was built
47    /// at: the epoch is current only while the tag matches
48    /// [`Client::reset_generation`].
49    Active(Arc<Epoch>, u64),
50    /// [`Client::close`] was called; the client is permanently closed.
51    Closed,
52}
53
54/// A high-level HTTP/3-over-SCION client: URL in, response out.
55///
56/// The client owns everything between a URL and a connection: SCION
57/// connectivity, DNS resolution, a connection pool, and timeouts. It is cheap
58/// to construct and intended to be a long-lived value shared across an
59/// application: it is `Send + Sync`, and requests borrow it immutably.
60///
61/// ```no_run
62/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
63/// use scion_http3::{Client, Config};
64///
65/// const MAX_BODY_SIZE: usize = 1 << 20;
66///
67/// let client = Client::new(Config::new("https://endhost-api.example.org".parse()?));
68/// let response = client.get("https://chat.example.org/rooms").await?;
69/// let (body, _trailers) = response.text(Some(MAX_BODY_SIZE)).await?;
70/// # Ok(())
71/// # }
72/// ```
73pub struct Client {
74    /// The current epoch, or the reason there is none.
75    epoch: tokio::sync::RwLock<EpochSlot>,
76    /// Bumped by [`reset`](Self::reset); the active epoch carries the value
77    /// it was built at and a mismatch marks it stale. It's a counter rather than
78    /// a flag so a reset that arrives while a rebuild runs is not lost:
79    /// the rebuild tags its epoch with a pre-build snapshot, which the reset
80    /// has already moved past.
81    reset_generation: AtomicU64,
82    /// The client configuration.
83    config: Config,
84    /// Test only: replaces [`Epoch::build`] so epoch lifecycle is testable
85    /// without a real stack.
86    #[cfg(test)]
87    epoch_factory: Option<EpochFactory>,
88}
89
90#[cfg(test)]
91type EpochFactory =
92    Box<dyn Fn() -> futures::future::BoxFuture<'static, Result<Epoch, Error>> + Send + Sync>;
93
94impl Client {
95    /// Creates a client. Performs no I/O: connectivity is built lazily by the
96    /// first request (or by [`warm_up`](Self::warm_up)).
97    #[must_use]
98    pub fn new(config: Config) -> Self {
99        Client {
100            epoch: tokio::sync::RwLock::new(EpochSlot::Init),
101            reset_generation: AtomicU64::new(0),
102            config,
103            #[cfg(test)]
104            epoch_factory: None,
105        }
106    }
107
108    /// Creates a client whose epochs come from `factory` instead of
109    /// [`Epoch::build`].
110    #[cfg(test)]
111    pub(crate) fn with_epoch_factory(config: Config, factory: EpochFactory) -> Self {
112        let mut client = Client::new(config);
113        client.epoch_factory = Some(factory);
114        client
115    }
116
117    /// Issues a request and returns the response once its head has arrived.
118    ///
119    /// The whole call, building connectivity if needed, resolving the
120    /// origin, establishing or reusing its connection, and waiting for the
121    /// response head, runs under the request timeout (the per-request
122    /// override, or the configured default). Collecting the body on the
123    /// returned [`Response`] runs under the remainder of the same deadline.
124    ///
125    /// If establishing a connection through a pooled client fails, the origin
126    /// is re-resolved and re-established once and the request re-issued. This
127    /// is safe for non-idempotent requests: establishment failure means
128    /// nothing reached the wire, and bodies are buffered and thus replayable.
129    /// Errors after the request reached the wire are never retried here; see
130    /// [`Error::is_retryable`] for what the caller may retry.
131    pub async fn request(&self, request: Request) -> Result<Response, Error> {
132        let timeout = request
133            .request_timeout()
134            .unwrap_or(self.config.request_timeout);
135        let deadline = deadline_after(timeout);
136        let (response, connection) = tokio::time::timeout_at(deadline, self.request_head(&request))
137            .await
138            .map_err(|_| {
139                Error::Timeout {
140                    phase: TimeoutPhase::Request,
141                    timeout,
142                }
143            })??;
144        Ok(Response::new(response, deadline, timeout, connection))
145    }
146
147    /// Issues a GET request to `url`. Shorthand for building a [`Request`]
148    /// with defaults and calling [`request`](Self::request).
149    pub async fn get(&self, url: impl IntoUrl) -> Result<Response, Error> {
150        let request = Request::get(url).build()?;
151        self.request(request).await
152    }
153
154    /// Issues a POST request with the given body to `url`. Shorthand for
155    /// building a [`Request`] with defaults and calling
156    /// [`request`](Self::request).
157    pub async fn post(&self, url: impl IntoUrl, body: impl Into<Bytes>) -> Result<Response, Error> {
158        let request = Request::post(url).body(body).build()?;
159        self.request(request).await
160    }
161
162    /// Opens a `CONNECT` tunnel to `authority`.
163    ///
164    /// The connection to the authority is resolved and pooled like the one of
165    /// a request to `https://host:port/`. The call runs under the request
166    /// timeout up to the response head; the open [`Tunnel`] has no deadline.
167    /// A non-2xx response is [`Error::TunnelRefused`].
168    pub async fn connect(&self, authority: &Authority) -> Result<Tunnel, Error> {
169        self.connect_via(authority, authority).await
170    }
171
172    /// Opens a `CONNECT` tunnel to `target` through the tunnel server at `via`.
173    ///
174    /// The client dials `via` and sends `target` as the `CONNECT` authority.
175    /// Otherwise it works like [`connect`](Self::connect).
176    pub async fn connect_via(&self, via: &Authority, target: &Authority) -> Result<Tunnel, Error> {
177        self.connect_tunnel(Origin::from_authority(via), target)
178            .await
179    }
180
181    /// Like [`connect_via`](Self::connect_via), but does not verify the
182    /// certificate of `via`.
183    ///
184    /// The caller must authenticate `target` inside the tunnel, for example
185    /// with TLS.
186    pub async fn connect_via_unverified(
187        &self,
188        via: &Authority,
189        target: &Authority,
190    ) -> Result<Tunnel, Error> {
191        self.connect_tunnel(Origin::unverified(via), target).await
192    }
193
194    /// Pre-establishes connectivity to `url`'s origin: builds the stack if
195    /// needed, resolves the origin, and performs the QUIC handshake, so the
196    /// first request pays none of it. Runs under the connect timeout (plus
197    /// stack build time).
198    pub async fn warm_up(&self, url: impl IntoUrl) -> Result<(), Error> {
199        let request = Request::get(url).build()?;
200        let origin = Origin::from_request(&request)?;
201        let epoch = self.current_epoch().await?;
202        let origin_client = epoch.origin_client(origin, Instant::now(), &self.config)?;
203        origin_client.connection().await?;
204        Ok(())
205    }
206
207    /// Marks connectivity stale: the next request tears down the current
208    /// connections and rebuilds the stack, discovery, and resolver state.
209    ///
210    /// Call this when the network below the client changed (for example on a
211    /// platform network-change signal). Never blocks; if the rebuild fails,
212    /// the failing request reports the build error and the next request
213    /// retries the rebuild.
214    pub fn reset(&self) {
215        self.reset_generation.fetch_add(1, Ordering::Release);
216    }
217
218    /// Closes the client: tears down all pooled connections, faulting
219    /// in-flight requests promptly. Subsequent requests fail with
220    /// [`Error::Closed`]. Idempotent.
221    pub async fn close(&self) {
222        let previous = {
223            let mut slot = self.epoch.write().await;
224            std::mem::replace(&mut *slot, EpochSlot::Closed)
225        };
226        if let EpochSlot::Active(epoch, _) = previous {
227            epoch.shutdown().await;
228        }
229    }
230
231    /// The request path up to the response head. The request timeout is
232    /// applied around this by [`request`](Self::request). Returns the
233    /// transport client alongside the response so the [`Response`] can keep
234    /// the connection alive while the body streams.
235    async fn request_head(
236        &self,
237        request: &Request,
238    ) -> Result<(http::Response<H3ResponseBody>, Arc<Http3Client>), Error> {
239        let origin = Origin::from_request(request)?;
240        self.with_connection(origin, |connection| {
241            async move { Ok(connection.request(request.to_http()?).await?) }
242        })
243        .await
244    }
245
246    /// The tunnel path up to the response head. The request timeout is
247    /// applied around this by [`connect`](Self::connect).
248    async fn connect_tunnel(&self, origin: Origin, target: &Authority) -> Result<Tunnel, Error> {
249        let timeout = self.config.request_timeout;
250        tokio::time::timeout_at(deadline_after(timeout), self.open_tunnel(origin, target))
251            .await
252            .map_err(|_| {
253                Error::Timeout {
254                    phase: TimeoutPhase::Request,
255                    timeout,
256                }
257            })?
258    }
259
260    async fn open_tunnel(&self, origin: Origin, target: &Authority) -> Result<Tunnel, Error> {
261        let (host, port) = (origin.host.clone(), origin.port);
262        let ((response, writer), connection) = self
263            .with_connection(origin, |connection| {
264                async move {
265                    Ok(connection
266                        .request_with_writer(connect_request(target))
267                        .await?)
268                }
269            })
270            .await?;
271        let response = response
272            .await
273            .map_err(|e| Error::from_h3_request_error(&host, port, e))?;
274        if !response.status().is_success() {
275            return Err(Error::TunnelRefused {
276                status: response.status(),
277            });
278        }
279        let stream = H3DuplexStream::new(writer, response.into_body());
280        Ok(Tunnel::new(stream, connection))
281    }
282
283    /// Runs `send` on the origin's pooled connection and returns its result
284    /// together with the connection.
285    ///
286    /// An establishment failure re-establishes once and runs `send` again;
287    /// nothing has reached the wire at that point.
288    async fn with_connection<T, F, Fut>(
289        &self,
290        origin: Origin,
291        send: F,
292    ) -> Result<(T, Arc<Http3Client>), Error>
293    where
294        F: Fn(Arc<Http3Client>) -> Fut,
295        Fut: Future<Output = Result<T, SendError>>,
296    {
297        let (host, port) = (origin.host.clone(), origin.port);
298        let epoch = self.current_epoch().await?;
299        let origin_client = epoch.origin_client(origin, Instant::now(), &self.config)?;
300
301        let (connection, generation) = origin_client.connection().await?;
302        match send(connection.clone()).await {
303            Ok(output) => Ok((output, connection)),
304            Err(SendError::Transport(H3RequestError::Establish(_))) => {
305                let (connection, _) = origin_client.reconnect(generation).await?;
306                let output = send(connection.clone())
307                    .await
308                    .map_err(|e| e.into_error(&host, port))?;
309                Ok((output, connection))
310            }
311            Err(e) => Err(e.into_error(&host, port)),
312        }
313    }
314
315    /// Returns the current epoch, building or rebuilding it if needed.
316    ///
317    /// Fast path: a read lock and a generation check. Slow path: the write
318    /// lock serializes rebuilds (concurrent requests block here, then take
319    /// the re-check and adopt the fresh epoch). On rebuild failure the old
320    /// epoch stays in place with its stale tag: requests fail fast with the
321    /// build error and the next one retries the rebuild.
322    async fn current_epoch(&self) -> Result<Arc<Epoch>, Error> {
323        {
324            let slot = self.epoch.read().await;
325            match &*slot {
326                EpochSlot::Closed => return Err(Error::Closed),
327                EpochSlot::Active(epoch, built)
328                    if *built == self.reset_generation.load(Ordering::Acquire) =>
329                {
330                    return Ok(epoch.clone());
331                }
332                _ => {}
333            }
334        }
335
336        let mut slot = self.epoch.write().await;
337        match &*slot {
338            EpochSlot::Closed => Err(Error::Closed),
339            EpochSlot::Active(epoch, built)
340                if *built == self.reset_generation.load(Ordering::Acquire) =>
341            {
342                Ok(epoch.clone())
343            }
344            _ => {
345                // Snapshot before building: a reset that arrives while the
346                // build runs leaves the counter ahead of this snapshot, so
347                // the next request rebuilds again instead of trusting an
348                // epoch built against the network that was just replaced.
349                let generation = self.reset_generation.load(Ordering::Acquire);
350                let fresh = Arc::new(self.build_epoch().await?);
351                let previous =
352                    std::mem::replace(&mut *slot, EpochSlot::Active(fresh.clone(), generation));
353                if let EpochSlot::Active(previous, _) = previous {
354                    // Best effort, off the lock: in-flight requests on the old
355                    // epoch are faulted promptly by the shutdown's close().
356                    tracing::debug!("Replacing SCION connectivity epoch");
357                    tokio::spawn(previous.shutdown());
358                }
359                Ok(fresh)
360            }
361        }
362    }
363
364    async fn build_epoch(&self) -> Result<Epoch, Error> {
365        #[cfg(test)]
366        if let Some(factory) = &self.epoch_factory {
367            return factory().await;
368        }
369        Epoch::build(&self.config).await
370    }
371}
372
373/// A failed send on a pooled connection.
374enum SendError {
375    Transport(H3RequestError),
376    Request(Error),
377}
378
379impl SendError {
380    fn into_error(self, host: &str, port: u16) -> Error {
381        match self {
382            SendError::Transport(e) => Error::from_h3_request_error(host, port, e),
383            SendError::Request(e) => e,
384        }
385    }
386}
387
388impl From<H3RequestError> for SendError {
389    fn from(e: H3RequestError) -> Self {
390        SendError::Transport(e)
391    }
392}
393
394impl From<Error> for SendError {
395    fn from(e: Error) -> Self {
396        SendError::Request(e)
397    }
398}
399
400/// The deadline `timeout` from now; an unrepresentable one becomes 30 years,
401/// as tokio's far future does.
402fn deadline_after(timeout: Duration) -> tokio::time::Instant {
403    let now = tokio::time::Instant::now();
404    now.checked_add(timeout)
405        .unwrap_or_else(|| now + Duration::from_hours(24 * 365 * 30))
406}
407
408#[cfg(test)]
409mod tests {
410    use std::{
411        sync::atomic::{AtomicUsize, Ordering::SeqCst},
412        time::Duration,
413    };
414
415    use scion_quic::quic::config::QuicConfig;
416    use test_log::test;
417    use tokio::io::{AsyncReadExt, AsyncWriteExt};
418
419    use super::*;
420    use crate::test_support::{
421        FailingResolver, SERVER_PORT, StaticResolver, TestServerHarness, TunnelEchoService,
422        server_scion_ip, test_config, test_router,
423    };
424
425    /// A client whose epochs are assembled from the given harness/resolver
426    /// instead of a real stack; `builds` counts epoch (re)builds.
427    fn harness_client(
428        harness: Arc<TestServerHarness>,
429        resolver: Arc<dyn scion_stack::resolver::ScionDnsResolver>,
430        config: Config,
431    ) -> (Client, Arc<AtomicUsize>) {
432        let builds = Arc::new(AtomicUsize::new(0));
433        let factory_builds = builds.clone();
434        let factory_config = config.clone();
435        let client = Client::with_epoch_factory(
436            config,
437            Box::new(move || {
438                factory_builds.fetch_add(1, SeqCst);
439                let harness = harness.clone();
440                let resolver = resolver.clone();
441                let config = factory_config.clone();
442                Box::pin(async move { Ok(Epoch::new(harness, resolver, &config)) })
443            }),
444        );
445        (client, builds)
446    }
447
448    fn url(host: &str, path: &str) -> String {
449        format!("https://{host}:{SERVER_PORT}{path}")
450    }
451
452    fn authority(host: &str) -> Authority {
453        Authority::new(host, SERVER_PORT).unwrap()
454    }
455
456    fn tunnel_client(config: Config) -> (Client, Arc<TestServerHarness>, TunnelEchoService) {
457        let service = TunnelEchoService::default();
458        let harness = TestServerHarness::with_tunnel_service(service.clone());
459        let resolver = StaticResolver::new(vec![server_scion_ip()]);
460        let (client, _) = harness_client(harness.clone(), resolver, config);
461        (client, harness, service)
462    }
463
464    async fn echo_round_trip(tunnel: &mut Tunnel, payload: &[u8]) {
465        tunnel.write_all(payload).await.unwrap();
466        tunnel.flush().await.unwrap();
467        let mut echoed = vec![0u8; payload.len()];
468        tunnel.read_exact(&mut echoed).await.unwrap();
469        assert_eq!(echoed, payload);
470    }
471
472    #[test(tokio::test)]
473    #[ntest::timeout(10_000)]
474    async fn connect_tunnels_bytes_both_ways() {
475        let (client, _, service) = tunnel_client(test_config());
476
477        let mut tunnel = client.connect(&authority("localhost")).await.unwrap();
478        for round in 0..3 {
479            echo_round_trip(&mut tunnel, format!("chunk-{round}").as_bytes()).await;
480        }
481        assert_eq!(
482            service.requests(),
483            vec![(
484                http::Method::CONNECT,
485                Some(authority("localhost").to_string())
486            )]
487        );
488
489        tunnel.shutdown().await.unwrap();
490        let mut buf = [0u8; 16];
491        assert_eq!(tunnel.read(&mut buf).await.unwrap(), 0);
492    }
493
494    #[test(tokio::test)]
495    #[ntest::timeout(10_000)]
496    async fn connect_via_sends_the_target_to_the_tunnel_server() {
497        let (client, _, service) = tunnel_client(test_config());
498        let target = Authority::new("backend.test", 8443).unwrap();
499
500        let mut tunnel = client
501            .connect_via(&authority("localhost"), &target)
502            .await
503            .unwrap();
504        echo_round_trip(&mut tunnel, b"through the tunnel server").await;
505        assert_eq!(
506            service.requests(),
507            vec![(http::Method::CONNECT, Some(target.to_string()))]
508        );
509    }
510
511    /// [`test_config`] with the peer check on, so the self-signed harness
512    /// certificate fails it.
513    fn verifying_config() -> Config {
514        let mut config = test_config();
515        config.quic.verify_peer = true;
516        config
517    }
518
519    #[test(tokio::test)]
520    #[ntest::timeout(10_000)]
521    async fn connect_via_unverified_skips_the_peer_check() {
522        let (client, _, service) = tunnel_client(verifying_config());
523        let target = Authority::new("backend.test", 5222).unwrap();
524
525        let mut tunnel = client
526            .connect_via_unverified(&authority("localhost"), &target)
527            .await
528            .unwrap();
529        echo_round_trip(&mut tunnel, b"through the tunnel server").await;
530        assert_eq!(
531            service.requests(),
532            vec![(http::Method::CONNECT, Some(target.to_string()))]
533        );
534    }
535
536    #[test(tokio::test)]
537    #[ntest::timeout(10_000)]
538    async fn an_unverified_connection_does_not_carry_a_verified_tunnel() {
539        let (client, ..) = tunnel_client(verifying_config());
540        let _unverified = client
541            .connect_via_unverified(&authority("localhost"), &authority("localhost"))
542            .await
543            .unwrap();
544
545        let err = client.connect(&authority("localhost")).await.err().unwrap();
546        assert!(
547            matches!(err, Error::Connect { .. } | Error::Tls { .. }),
548            "{err:?}"
549        );
550    }
551
552    #[test(tokio::test)]
553    #[ntest::timeout(10_000)]
554    async fn connect_maps_non_2xx_to_tunnel_refused() {
555        let (client, _, service) = tunnel_client(test_config());
556
557        service.set_status(http::StatusCode::NOT_FOUND);
558        let err = client.connect(&authority("localhost")).await.err().unwrap();
559        assert!(
560            matches!(
561                err,
562                Error::TunnelRefused {
563                    status: http::StatusCode::NOT_FOUND
564                }
565            ),
566            "{err}"
567        );
568        assert!(!err.is_retryable());
569
570        service.set_status(http::StatusCode::BAD_GATEWAY);
571        let err = client.connect(&authority("localhost")).await.err().unwrap();
572        assert!(
573            matches!(
574                err,
575                Error::TunnelRefused {
576                    status: http::StatusCode::BAD_GATEWAY
577                }
578            ),
579            "{err}"
580        );
581        assert!(err.is_retryable());
582    }
583
584    #[test(tokio::test)]
585    #[ntest::timeout(10_000)]
586    async fn connect_shares_the_pooled_connection() {
587        let (client, harness, _) = tunnel_client(test_config());
588
589        let mut first = client.connect(&authority("localhost")).await.unwrap();
590        let mut second = client.connect(&authority("localhost")).await.unwrap();
591        echo_round_trip(&mut first, b"first").await;
592        echo_round_trip(&mut second, b"second").await;
593        assert_eq!(harness.binds.load(SeqCst), 1);
594    }
595
596    #[test(tokio::test)]
597    #[ntest::timeout(10_000)]
598    async fn connect_reuses_the_establish_retry() {
599        let (client, harness, _) = tunnel_client(test_config());
600
601        let tunnel = client.connect(&authority("localhost")).await.unwrap();
602        assert_eq!(harness.binds.load(SeqCst), 1);
603        drop(tunnel);
604
605        harness.break_sockets();
606        tokio::time::sleep(Duration::from_millis(200)).await;
607
608        let mut tunnel = client.connect(&authority("localhost")).await.unwrap();
609        echo_round_trip(&mut tunnel, b"after reconnect").await;
610        assert_eq!(harness.binds.load(SeqCst), 2);
611    }
612
613    #[test(tokio::test)]
614    #[ntest::timeout(10_000)]
615    async fn tunnel_survives_origin_eviction() {
616        let (client, ..) = tunnel_client(test_config().with_max_origins(1));
617
618        let mut tunnel = client.connect(&authority("a.local")).await.unwrap();
619        echo_round_trip(&mut tunnel, b"before eviction").await;
620
621        let mut other = client.connect(&authority("b.local")).await.unwrap();
622        echo_round_trip(&mut other, b"other origin").await;
623
624        echo_round_trip(&mut tunnel, b"after eviction").await;
625    }
626
627    #[test(tokio::test)]
628    #[ntest::timeout(10_000)]
629    async fn close_faults_open_tunnel() {
630        let (client, ..) = tunnel_client(test_config());
631        let client = Arc::new(client);
632
633        let mut tunnel = client.connect(&authority("localhost")).await.unwrap();
634        let pending = tokio::spawn(async move {
635            let mut buf = [0u8; 16];
636            tunnel.read(&mut buf).await
637        });
638        tokio::time::sleep(Duration::from_millis(100)).await;
639        client.close().await;
640
641        let start = tokio::time::Instant::now();
642        assert!(pending.await.unwrap().is_err());
643        assert!(start.elapsed() < Duration::from_millis(300));
644    }
645
646    #[test(tokio::test)]
647    #[ntest::timeout(10_000)]
648    async fn get_resolves_via_dns_path() {
649        let (router, hits) = test_router();
650        let harness = TestServerHarness::new(router);
651        let resolver = StaticResolver::new(vec![server_scion_ip()]);
652        let (client, _) = harness_client(harness, resolver, test_config());
653
654        let response = client.get(url("localhost", "/hello")).await.unwrap();
655        assert!(response.is_success());
656        let (body, _) = response.text(Some(1024)).await.unwrap();
657        assert_eq!(body, "world");
658        assert_eq!(hits.load(SeqCst), 1);
659    }
660
661    #[test(tokio::test)]
662    #[ntest::timeout(10_000)]
663    async fn duration_max_timeout_does_not_panic() {
664        let (router, _) = test_router();
665        let harness = TestServerHarness::new(router);
666        let resolver = StaticResolver::new(vec![server_scion_ip()]);
667        let (client, _) = harness_client(harness, resolver, test_config());
668
669        let request = Request::get(url("localhost", "/hello"))
670            .request_timeout(Duration::MAX)
671            .build()
672            .unwrap();
673        let response = client.request(request).await.unwrap();
674        assert!(response.is_success());
675    }
676
677    #[test(tokio::test)]
678    #[ntest::timeout(10_000)]
679    async fn resolution_failure_surfaces_as_retryable_resolution_error() {
680        let (router, _) = test_router();
681        let harness = TestServerHarness::new(router);
682        let (client, _) = harness_client(harness, Arc::new(FailingResolver), test_config());
683
684        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
685        assert!(matches!(err, Error::Resolution { .. }), "{err}");
686        assert!(err.is_retryable());
687    }
688
689    #[test(tokio::test)]
690    #[ntest::timeout(10_000)]
691    async fn establish_failure_reconnects_once_and_retries() {
692        let (router, hits) = test_router();
693        let harness = TestServerHarness::new(router);
694        let resolver = StaticResolver::new(vec![server_scion_ip()]);
695        let (client, _) = harness_client(harness.clone(), resolver, test_config());
696
697        let response = client.get(url("localhost", "/hello")).await.unwrap();
698        assert!(response.is_success());
699        assert_eq!(harness.binds.load(SeqCst), 1);
700
701        // Break the established connection by taking its socket away. The
702        // pooled client's own re-establishment (same, still broken socket)
703        // then fails, so the request path re-establishes through the pool
704        // (fresh socket, fresh server) and retries — invisible to the caller.
705        harness.break_sockets();
706        // Long enough for the client to notice the broken socket, which takes
707        // a couple of task wake-ups rather than any timeout.
708        tokio::time::sleep(Duration::from_millis(200)).await;
709
710        let response = client.get(url("localhost", "/hello")).await.unwrap();
711        assert!(response.is_success());
712        assert_eq!(harness.binds.load(SeqCst), 2);
713        assert_eq!(hits.load(SeqCst), 2);
714    }
715
716    #[test(tokio::test)]
717    #[ntest::timeout(10_000)]
718    async fn stream_level_errors_are_not_retried() {
719        let (router, _) = test_router();
720        // A server that allows zero concurrent request streams: the request
721        // fails at the stream level, after establishment.
722        let mut server_quic = QuicConfig::builder().verify_peer(false).build();
723        server_quic.initial_max_streams_bidi = 0;
724        let harness = TestServerHarness::with_server_quic(router, server_quic);
725        let resolver = StaticResolver::new(vec![server_scion_ip()]);
726        let (client, _) = harness_client(harness.clone(), resolver, test_config());
727
728        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
729        assert!(matches!(err, Error::ConnectionLimit), "{err}");
730        // No re-establishment was attempted: stream-level failures are not
731        // establishment failures, so the establish-retry must not fire.
732        assert_eq!(harness.binds.load(SeqCst), 1);
733    }
734
735    #[test(tokio::test)]
736    #[ntest::timeout(10_000)]
737    async fn reset_is_lazy_and_rebuild_is_single_flight() {
738        let (router, _) = test_router();
739        let harness = TestServerHarness::new(router);
740        let resolver = StaticResolver::new(vec![server_scion_ip()]);
741        let (client, builds) = harness_client(harness, resolver, test_config());
742        let client = Arc::new(client);
743
744        client.get(url("localhost", "/hello")).await.unwrap();
745        assert_eq!(builds.load(SeqCst), 1);
746
747        // Reset alone does nothing: the rebuild happens on the next request.
748        client.reset();
749        assert_eq!(builds.load(SeqCst), 1);
750
751        // Concurrent requests after a reset trigger exactly one rebuild: the
752        // write lock is the single-flight.
753        let tasks: Vec<_> = (0..3)
754            .map(|_| {
755                let client = client.clone();
756                tokio::spawn(async move { client.get(url("localhost", "/hello")).await })
757            })
758            .collect();
759        for task in tasks {
760            task.await.unwrap().unwrap();
761        }
762        assert_eq!(builds.load(SeqCst), 2);
763    }
764
765    #[test(tokio::test)]
766    #[ntest::timeout(10_000)]
767    async fn reset_during_rebuild_triggers_another_rebuild() {
768        let (router, _) = test_router();
769        let harness = TestServerHarness::new(router);
770        let resolver = StaticResolver::new(vec![server_scion_ip()]);
771        let builds = Arc::new(AtomicUsize::new(0));
772        // Every build consumes one permit, so the test controls when a
773        // rebuild may complete.
774        let gate = Arc::new(tokio::sync::Semaphore::new(1));
775        let factory_builds = builds.clone();
776        let factory_gate = gate.clone();
777        let config = test_config();
778        let factory_config = config.clone();
779        let client = Arc::new(Client::with_epoch_factory(
780            config,
781            Box::new(move || {
782                let builds = factory_builds.clone();
783                let gate = factory_gate.clone();
784                let harness = harness.clone();
785                let resolver = resolver.clone();
786                let config = factory_config.clone();
787                Box::pin(async move {
788                    gate.acquire().await.expect("gate closed").forget();
789                    builds.fetch_add(1, SeqCst);
790                    Ok(Epoch::new(harness, resolver, &config))
791                })
792            }),
793        ));
794
795        client.get(url("localhost", "/hello")).await.unwrap();
796        assert_eq!(builds.load(SeqCst), 1);
797
798        // Start a rebuild and hold it open at the gate...
799        client.reset();
800        let pending = {
801            let client = client.clone();
802            tokio::spawn(async move { client.get(url("localhost", "/hello")).await })
803        };
804        tokio::time::sleep(Duration::from_millis(100)).await;
805        assert_eq!(builds.load(SeqCst), 1);
806
807        // ...then reset again while the rebuild is running (the network
808        // changed again mid-build), and let the rebuild finish.
809        client.reset();
810        gate.add_permits(2);
811        pending.await.unwrap().unwrap();
812        assert_eq!(builds.load(SeqCst), 2);
813
814        // The mid-build reset must not be swallowed: the next request
815        // rebuilds again instead of trusting the epoch built against the
816        // network that was replaced under it.
817        client.get(url("localhost", "/hello")).await.unwrap();
818        assert_eq!(builds.load(SeqCst), 3);
819    }
820
821    #[test(tokio::test)]
822    #[ntest::timeout(10_000)]
823    async fn failed_rebuild_keeps_stale_and_retries_next_request() {
824        let (router, _) = test_router();
825        let harness = TestServerHarness::new(router);
826        let resolver = StaticResolver::new(vec![server_scion_ip()]);
827        let builds = Arc::new(AtomicUsize::new(0));
828        let factory_builds = builds.clone();
829        let config = test_config();
830        let factory_config = config.clone();
831        let client = Client::with_epoch_factory(
832            config,
833            Box::new(move || {
834                let build = factory_builds.fetch_add(1, SeqCst) + 1;
835                let harness = harness.clone();
836                let resolver = resolver.clone();
837                let config = factory_config.clone();
838                Box::pin(async move {
839                    if build == 2 {
840                        return Err(Error::StackBuild {
841                            retryable: true,
842                            source: "flaky discovery".into(),
843                        });
844                    }
845                    Ok(Epoch::new(harness, resolver, &config))
846                })
847            }),
848        );
849
850        client.get(url("localhost", "/hello")).await.unwrap();
851        client.reset();
852
853        // The rebuild fails: the request reports the build error...
854        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
855        assert!(matches!(err, Error::StackBuild { .. }), "{err}");
856        assert!(err.is_retryable());
857
858        // ...and the next request retries the rebuild and succeeds.
859        client.get(url("localhost", "/hello")).await.unwrap();
860        assert_eq!(builds.load(SeqCst), 3);
861    }
862
863    #[test(tokio::test)]
864    #[ntest::timeout(10_000)]
865    async fn epochs_do_not_share_stacks_across_reset() {
866        // After a reset, sockets come from the new epoch's binder only.
867        let (router_one, _) = test_router();
868        let (router_two, _) = test_router();
869        let first = TestServerHarness::new(router_one);
870        let second = TestServerHarness::new(router_two);
871        let resolver = StaticResolver::new(vec![server_scion_ip()]);
872        let builds = Arc::new(AtomicUsize::new(0));
873        let factory_builds = builds.clone();
874        let config = test_config();
875        let factory_config = config.clone();
876        let factory_first = first.clone();
877        let factory_second = second.clone();
878        let client = Client::with_epoch_factory(
879            config,
880            Box::new(move || {
881                let build = factory_builds.fetch_add(1, SeqCst) + 1;
882                let harness = if build == 1 {
883                    factory_first.clone()
884                } else {
885                    factory_second.clone()
886                };
887                let resolver = resolver.clone();
888                let config = factory_config.clone();
889                Box::pin(async move { Ok(Epoch::new(harness, resolver, &config)) })
890            }),
891        );
892
893        client.get(url("localhost", "/hello")).await.unwrap();
894        assert_eq!(
895            (first.binds.load(SeqCst), second.binds.load(SeqCst)),
896            (1, 0)
897        );
898
899        client.reset();
900        client.get(url("localhost", "/hello")).await.unwrap();
901        assert_eq!(
902            (first.binds.load(SeqCst), second.binds.load(SeqCst)),
903            (1, 1)
904        );
905    }
906
907    #[test(tokio::test)]
908    #[ntest::timeout(10_000)]
909    async fn stalled_establishment_does_not_block_other_origins() {
910        // One origin stuck in establishment holds only its own
911        // single-flight; other origins proceed.
912        let (router, _) = test_router();
913        let harness = TestServerHarness::new(router);
914        harness.stall_first_bind();
915        let resolver = StaticResolver::new(vec![server_scion_ip()]);
916        let (client, _) = harness_client(harness.clone(), resolver, test_config());
917        let client = Arc::new(client);
918
919        let stalled = {
920            let client = client.clone();
921            tokio::spawn(async move { client.get(url("a.local", "/hello")).await })
922        };
923        tokio::time::sleep(Duration::from_millis(100)).await;
924        assert!(!stalled.is_finished());
925
926        // A different origin (different host, same live server) completes
927        // while the first is still stalled.
928        let response = client.get(url("b.local", "/hello")).await.unwrap();
929        assert!(response.is_success());
930        assert!(!stalled.is_finished());
931
932        harness.release_stalled();
933        stalled.await.unwrap().unwrap();
934    }
935
936    #[test(tokio::test)]
937    #[ntest::timeout(10_000)]
938    async fn evicted_origin_completes_in_flight_request() {
939        // Removal is not destruction — an in-flight request keeps its
940        // client alive through the `Arc` and completes after eviction.
941        let (router, _) = test_router();
942        let harness = TestServerHarness::new(router);
943        let resolver = StaticResolver::new(vec![server_scion_ip()]);
944        let (client, _) = harness_client(harness, resolver, test_config().with_max_origins(1));
945        let client = Arc::new(client);
946
947        let slow = {
948            let client = client.clone();
949            tokio::spawn(async move {
950                let response = client.get(url("a.local", "/slow")).await?;
951                response.text(Some(1024)).await
952            })
953        };
954        tokio::time::sleep(Duration::from_millis(150)).await;
955
956        // This inserts a second origin into a map capped at one, evicting the
957        // origin with the in-flight request.
958        client.get(url("b.local", "/hello")).await.unwrap();
959
960        let (body, _) = slow.await.unwrap().unwrap();
961        assert_eq!(body, "slow");
962    }
963
964    #[test(tokio::test)]
965    #[ntest::timeout(10_000)]
966    async fn close_rejects_new_requests_and_faults_in_flight() {
967        let (router, _) = test_router();
968        let harness = TestServerHarness::new(router);
969        let resolver = StaticResolver::new(vec![server_scion_ip()]);
970        let (client, _) = harness_client(harness, resolver, test_config());
971        let client = Arc::new(client);
972
973        client.get(url("localhost", "/hello")).await.unwrap();
974
975        // An in-flight request faults promptly on close instead of running to
976        // completion or timing out.
977        let in_flight = {
978            let client = client.clone();
979            tokio::spawn(async move {
980                let response = client.get(url("localhost", "/slow")).await?;
981                response.text(Some(1024)).await
982            })
983        };
984        tokio::time::sleep(Duration::from_millis(100)).await;
985        client.close().await;
986
987        let start = tokio::time::Instant::now();
988        let result = in_flight.await.unwrap();
989        assert!(result.is_err());
990        assert!(start.elapsed() < Duration::from_millis(300));
991
992        // The client is permanently closed; closing again is a no-op.
993        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
994        assert!(matches!(err, Error::Closed), "{err}");
995        client.close().await;
996    }
997
998    #[test(tokio::test)]
999    #[ntest::timeout(10_000)]
1000    async fn per_request_timeout_overrides_the_default() {
1001        let (router, _) = test_router();
1002        let harness = TestServerHarness::new(router);
1003        let resolver = StaticResolver::new(vec![server_scion_ip()]);
1004        let (client, _) = harness_client(harness, resolver, test_config());
1005
1006        let request = Request::get(url("localhost", "/slow"))
1007            .request_timeout(Duration::from_millis(100))
1008            .build()
1009            .unwrap();
1010        let err = client.request(request).await.err().unwrap();
1011        assert!(
1012            matches!(
1013                err,
1014                Error::Timeout {
1015                    phase: TimeoutPhase::Request,
1016                    ..
1017                }
1018            ),
1019            "{err}"
1020        );
1021    }
1022
1023    #[test(tokio::test)]
1024    #[ntest::timeout(10_000)]
1025    async fn body_collection_respects_max_size() {
1026        let (router, _) = test_router();
1027        let harness = TestServerHarness::new(router);
1028        let resolver = StaticResolver::new(vec![server_scion_ip()]);
1029        let (client, _) = harness_client(harness, resolver, test_config());
1030
1031        let response = client.get(url("localhost", "/hello")).await.unwrap();
1032        let err = response.bytes(Some(2)).await.err().unwrap();
1033        assert!(matches!(err, Error::BodyTooLarge { limit: 2 }), "{err}");
1034    }
1035
1036    #[test(tokio::test)]
1037    #[ntest::timeout(10_000)]
1038    async fn warm_up_establishes_without_a_request() {
1039        let (router, hits) = test_router();
1040        let harness = TestServerHarness::new(router);
1041        let resolver = StaticResolver::new(vec![server_scion_ip()]);
1042        let (client, builds) = harness_client(harness.clone(), resolver, test_config());
1043
1044        client.warm_up(url("localhost", "/")).await.unwrap();
1045        assert_eq!(builds.load(SeqCst), 1);
1046        assert_eq!(harness.binds.load(SeqCst), 1);
1047        assert_eq!(hits.load(SeqCst), 0);
1048
1049        // The warmed-up connection is the one the request uses.
1050        client.get(url("localhost", "/hello")).await.unwrap();
1051        assert_eq!(harness.binds.load(SeqCst), 1);
1052        assert_eq!(hits.load(SeqCst), 1);
1053    }
1054}