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        let timeout = self.config.request_timeout;
170        tokio::time::timeout_at(deadline_after(timeout), self.connect_tunnel(authority))
171            .await
172            .map_err(|_| {
173                Error::Timeout {
174                    phase: TimeoutPhase::Request,
175                    timeout,
176                }
177            })?
178    }
179
180    /// Pre-establishes connectivity to `url`'s origin: builds the stack if
181    /// needed, resolves the origin, and performs the QUIC handshake, so the
182    /// first request pays none of it. Runs under the connect timeout (plus
183    /// stack build time).
184    pub async fn warm_up(&self, url: impl IntoUrl) -> Result<(), Error> {
185        let request = Request::get(url).build()?;
186        let origin = Origin::from_request(&request)?;
187        let epoch = self.current_epoch().await?;
188        let origin_client = epoch.origin_client(origin, Instant::now(), &self.config)?;
189        origin_client.connection().await?;
190        Ok(())
191    }
192
193    /// Marks connectivity stale: the next request tears down the current
194    /// connections and rebuilds the stack, discovery, and resolver state.
195    ///
196    /// Call this when the network below the client changed (for example on a
197    /// platform network-change signal). Never blocks; if the rebuild fails,
198    /// the failing request reports the build error and the next request
199    /// retries the rebuild.
200    pub fn reset(&self) {
201        self.reset_generation.fetch_add(1, Ordering::Release);
202    }
203
204    /// Closes the client: tears down all pooled connections, faulting
205    /// in-flight requests promptly. Subsequent requests fail with
206    /// [`Error::Closed`]. Idempotent.
207    pub async fn close(&self) {
208        let previous = {
209            let mut slot = self.epoch.write().await;
210            std::mem::replace(&mut *slot, EpochSlot::Closed)
211        };
212        if let EpochSlot::Active(epoch, _) = previous {
213            epoch.shutdown().await;
214        }
215    }
216
217    /// The request path up to the response head. The request timeout is
218    /// applied around this by [`request`](Self::request). Returns the
219    /// transport client alongside the response so the [`Response`] can keep
220    /// the connection alive while the body streams.
221    async fn request_head(
222        &self,
223        request: &Request,
224    ) -> Result<(http::Response<H3ResponseBody>, Arc<Http3Client>), Error> {
225        let origin = Origin::from_request(request)?;
226        self.with_connection(origin, |connection| {
227            async move { Ok(connection.request(request.to_http()?).await?) }
228        })
229        .await
230    }
231
232    /// The tunnel path up to the response head. The request timeout is
233    /// applied around this by [`connect`](Self::connect).
234    async fn connect_tunnel(&self, authority: &Authority) -> Result<Tunnel, Error> {
235        let origin = Origin::from_authority(authority);
236        let (host, port) = (origin.host.clone(), origin.port);
237        let ((response, writer), connection) = self
238            .with_connection(origin, |connection| {
239                async move {
240                    Ok(connection
241                        .request_with_writer(connect_request(authority))
242                        .await?)
243                }
244            })
245            .await?;
246        let response = response
247            .await
248            .map_err(|e| Error::from_h3_request_error(&host, port, e))?;
249        if !response.status().is_success() {
250            return Err(Error::TunnelRefused {
251                status: response.status(),
252            });
253        }
254        let stream = H3DuplexStream::new(writer, response.into_body());
255        Ok(Tunnel::new(stream, connection))
256    }
257
258    /// Runs `send` on the origin's pooled connection and returns its result
259    /// together with the connection.
260    ///
261    /// An establishment failure re-establishes once and runs `send` again;
262    /// nothing has reached the wire at that point.
263    async fn with_connection<T, F, Fut>(
264        &self,
265        origin: Origin,
266        send: F,
267    ) -> Result<(T, Arc<Http3Client>), Error>
268    where
269        F: Fn(Arc<Http3Client>) -> Fut,
270        Fut: Future<Output = Result<T, SendError>>,
271    {
272        let (host, port) = (origin.host.clone(), origin.port);
273        let epoch = self.current_epoch().await?;
274        let origin_client = epoch.origin_client(origin, Instant::now(), &self.config)?;
275
276        let (connection, generation) = origin_client.connection().await?;
277        match send(connection.clone()).await {
278            Ok(output) => Ok((output, connection)),
279            Err(SendError::Transport(H3RequestError::Establish(_))) => {
280                let (connection, _) = origin_client.reconnect(generation).await?;
281                let output = send(connection.clone())
282                    .await
283                    .map_err(|e| e.into_error(&host, port))?;
284                Ok((output, connection))
285            }
286            Err(e) => Err(e.into_error(&host, port)),
287        }
288    }
289
290    /// Returns the current epoch, building or rebuilding it if needed.
291    ///
292    /// Fast path: a read lock and a generation check. Slow path: the write
293    /// lock serializes rebuilds (concurrent requests block here, then take
294    /// the re-check and adopt the fresh epoch). On rebuild failure the old
295    /// epoch stays in place with its stale tag: requests fail fast with the
296    /// build error and the next one retries the rebuild.
297    async fn current_epoch(&self) -> Result<Arc<Epoch>, Error> {
298        {
299            let slot = self.epoch.read().await;
300            match &*slot {
301                EpochSlot::Closed => return Err(Error::Closed),
302                EpochSlot::Active(epoch, built)
303                    if *built == self.reset_generation.load(Ordering::Acquire) =>
304                {
305                    return Ok(epoch.clone());
306                }
307                _ => {}
308            }
309        }
310
311        let mut slot = self.epoch.write().await;
312        match &*slot {
313            EpochSlot::Closed => Err(Error::Closed),
314            EpochSlot::Active(epoch, built)
315                if *built == self.reset_generation.load(Ordering::Acquire) =>
316            {
317                Ok(epoch.clone())
318            }
319            _ => {
320                // Snapshot before building: a reset that arrives while the
321                // build runs leaves the counter ahead of this snapshot, so
322                // the next request rebuilds again instead of trusting an
323                // epoch built against the network that was just replaced.
324                let generation = self.reset_generation.load(Ordering::Acquire);
325                let fresh = Arc::new(self.build_epoch().await?);
326                let previous =
327                    std::mem::replace(&mut *slot, EpochSlot::Active(fresh.clone(), generation));
328                if let EpochSlot::Active(previous, _) = previous {
329                    // Best effort, off the lock: in-flight requests on the old
330                    // epoch are faulted promptly by the shutdown's close().
331                    tracing::debug!("Replacing SCION connectivity epoch");
332                    tokio::spawn(previous.shutdown());
333                }
334                Ok(fresh)
335            }
336        }
337    }
338
339    async fn build_epoch(&self) -> Result<Epoch, Error> {
340        #[cfg(test)]
341        if let Some(factory) = &self.epoch_factory {
342            return factory().await;
343        }
344        Epoch::build(&self.config).await
345    }
346}
347
348/// A failed send on a pooled connection.
349enum SendError {
350    Transport(H3RequestError),
351    Request(Error),
352}
353
354impl SendError {
355    fn into_error(self, host: &str, port: u16) -> Error {
356        match self {
357            SendError::Transport(e) => Error::from_h3_request_error(host, port, e),
358            SendError::Request(e) => e,
359        }
360    }
361}
362
363impl From<H3RequestError> for SendError {
364    fn from(e: H3RequestError) -> Self {
365        SendError::Transport(e)
366    }
367}
368
369impl From<Error> for SendError {
370    fn from(e: Error) -> Self {
371        SendError::Request(e)
372    }
373}
374
375/// The deadline `timeout` from now; an unrepresentable one becomes 30 years,
376/// as tokio's far future does.
377fn deadline_after(timeout: Duration) -> tokio::time::Instant {
378    let now = tokio::time::Instant::now();
379    now.checked_add(timeout)
380        .unwrap_or_else(|| now + Duration::from_hours(24 * 365 * 30))
381}
382
383#[cfg(test)]
384mod tests {
385    use std::{
386        sync::atomic::{AtomicUsize, Ordering::SeqCst},
387        time::Duration,
388    };
389
390    use scion_quic::quic::config::QuicConfig;
391    use test_log::test;
392    use tokio::io::{AsyncReadExt, AsyncWriteExt};
393
394    use super::*;
395    use crate::test_support::{
396        FailingResolver, SERVER_PORT, StaticResolver, TestServerHarness, TunnelEchoService,
397        server_scion_ip, test_config, test_router,
398    };
399
400    /// A client whose epochs are assembled from the given harness/resolver
401    /// instead of a real stack; `builds` counts epoch (re)builds.
402    fn harness_client(
403        harness: Arc<TestServerHarness>,
404        resolver: Arc<dyn scion_stack::resolver::ScionDnsResolver>,
405        config: Config,
406    ) -> (Client, Arc<AtomicUsize>) {
407        let builds = Arc::new(AtomicUsize::new(0));
408        let factory_builds = builds.clone();
409        let factory_config = config.clone();
410        let client = Client::with_epoch_factory(
411            config,
412            Box::new(move || {
413                factory_builds.fetch_add(1, SeqCst);
414                let harness = harness.clone();
415                let resolver = resolver.clone();
416                let config = factory_config.clone();
417                Box::pin(async move { Ok(Epoch::new(harness, resolver, &config)) })
418            }),
419        );
420        (client, builds)
421    }
422
423    fn url(host: &str, path: &str) -> String {
424        format!("https://{host}:{SERVER_PORT}{path}")
425    }
426
427    fn authority(host: &str) -> Authority {
428        Authority::new(host, SERVER_PORT).unwrap()
429    }
430
431    fn tunnel_client(config: Config) -> (Client, Arc<TestServerHarness>, TunnelEchoService) {
432        let service = TunnelEchoService::default();
433        let harness = TestServerHarness::with_tunnel_service(service.clone());
434        let resolver = StaticResolver::new(vec![server_scion_ip()]);
435        let (client, _) = harness_client(harness.clone(), resolver, config);
436        (client, harness, service)
437    }
438
439    async fn echo_round_trip(tunnel: &mut Tunnel, payload: &[u8]) {
440        tunnel.write_all(payload).await.unwrap();
441        tunnel.flush().await.unwrap();
442        let mut echoed = vec![0u8; payload.len()];
443        tunnel.read_exact(&mut echoed).await.unwrap();
444        assert_eq!(echoed, payload);
445    }
446
447    #[test(tokio::test)]
448    #[ntest::timeout(10_000)]
449    async fn connect_tunnels_bytes_both_ways() {
450        let (client, _, service) = tunnel_client(test_config());
451
452        let mut tunnel = client.connect(&authority("localhost")).await.unwrap();
453        for round in 0..3 {
454            echo_round_trip(&mut tunnel, format!("chunk-{round}").as_bytes()).await;
455        }
456        assert_eq!(
457            service.requests(),
458            vec![(
459                http::Method::CONNECT,
460                Some(authority("localhost").to_string())
461            )]
462        );
463
464        tunnel.shutdown().await.unwrap();
465        let mut buf = [0u8; 16];
466        assert_eq!(tunnel.read(&mut buf).await.unwrap(), 0);
467    }
468
469    #[test(tokio::test)]
470    #[ntest::timeout(10_000)]
471    async fn connect_maps_non_2xx_to_tunnel_refused() {
472        let (client, _, service) = tunnel_client(test_config());
473
474        service.set_status(http::StatusCode::NOT_FOUND);
475        let err = client.connect(&authority("localhost")).await.err().unwrap();
476        assert!(
477            matches!(
478                err,
479                Error::TunnelRefused {
480                    status: http::StatusCode::NOT_FOUND
481                }
482            ),
483            "{err}"
484        );
485        assert!(!err.is_retryable());
486
487        service.set_status(http::StatusCode::BAD_GATEWAY);
488        let err = client.connect(&authority("localhost")).await.err().unwrap();
489        assert!(
490            matches!(
491                err,
492                Error::TunnelRefused {
493                    status: http::StatusCode::BAD_GATEWAY
494                }
495            ),
496            "{err}"
497        );
498        assert!(err.is_retryable());
499    }
500
501    #[test(tokio::test)]
502    #[ntest::timeout(10_000)]
503    async fn connect_shares_the_pooled_connection() {
504        let (client, harness, _) = tunnel_client(test_config());
505
506        let mut first = client.connect(&authority("localhost")).await.unwrap();
507        let mut second = client.connect(&authority("localhost")).await.unwrap();
508        echo_round_trip(&mut first, b"first").await;
509        echo_round_trip(&mut second, b"second").await;
510        assert_eq!(harness.binds.load(SeqCst), 1);
511    }
512
513    #[test(tokio::test)]
514    #[ntest::timeout(10_000)]
515    async fn connect_reuses_the_establish_retry() {
516        let (client, harness, _) = tunnel_client(test_config());
517
518        let tunnel = client.connect(&authority("localhost")).await.unwrap();
519        assert_eq!(harness.binds.load(SeqCst), 1);
520        drop(tunnel);
521
522        harness.break_sockets();
523        tokio::time::sleep(Duration::from_millis(200)).await;
524
525        let mut tunnel = client.connect(&authority("localhost")).await.unwrap();
526        echo_round_trip(&mut tunnel, b"after reconnect").await;
527        assert_eq!(harness.binds.load(SeqCst), 2);
528    }
529
530    #[test(tokio::test)]
531    #[ntest::timeout(10_000)]
532    async fn tunnel_survives_origin_eviction() {
533        let (client, ..) = tunnel_client(test_config().with_max_origins(1));
534
535        let mut tunnel = client.connect(&authority("a.local")).await.unwrap();
536        echo_round_trip(&mut tunnel, b"before eviction").await;
537
538        let mut other = client.connect(&authority("b.local")).await.unwrap();
539        echo_round_trip(&mut other, b"other origin").await;
540
541        echo_round_trip(&mut tunnel, b"after eviction").await;
542    }
543
544    #[test(tokio::test)]
545    #[ntest::timeout(10_000)]
546    async fn close_faults_open_tunnel() {
547        let (client, ..) = tunnel_client(test_config());
548        let client = Arc::new(client);
549
550        let mut tunnel = client.connect(&authority("localhost")).await.unwrap();
551        let pending = tokio::spawn(async move {
552            let mut buf = [0u8; 16];
553            tunnel.read(&mut buf).await
554        });
555        tokio::time::sleep(Duration::from_millis(100)).await;
556        client.close().await;
557
558        let start = tokio::time::Instant::now();
559        assert!(pending.await.unwrap().is_err());
560        assert!(start.elapsed() < Duration::from_millis(300));
561    }
562
563    #[test(tokio::test)]
564    #[ntest::timeout(10_000)]
565    async fn get_resolves_via_dns_path() {
566        let (router, hits) = test_router();
567        let harness = TestServerHarness::new(router);
568        let resolver = StaticResolver::new(vec![server_scion_ip()]);
569        let (client, _) = harness_client(harness, resolver, test_config());
570
571        let response = client.get(url("localhost", "/hello")).await.unwrap();
572        assert!(response.is_success());
573        let (body, _) = response.text(Some(1024)).await.unwrap();
574        assert_eq!(body, "world");
575        assert_eq!(hits.load(SeqCst), 1);
576    }
577
578    #[test(tokio::test)]
579    #[ntest::timeout(10_000)]
580    async fn duration_max_timeout_does_not_panic() {
581        let (router, _) = test_router();
582        let harness = TestServerHarness::new(router);
583        let resolver = StaticResolver::new(vec![server_scion_ip()]);
584        let (client, _) = harness_client(harness, resolver, test_config());
585
586        let request = Request::get(url("localhost", "/hello"))
587            .request_timeout(Duration::MAX)
588            .build()
589            .unwrap();
590        let response = client.request(request).await.unwrap();
591        assert!(response.is_success());
592    }
593
594    #[test(tokio::test)]
595    #[ntest::timeout(10_000)]
596    async fn resolution_failure_surfaces_as_retryable_resolution_error() {
597        let (router, _) = test_router();
598        let harness = TestServerHarness::new(router);
599        let (client, _) = harness_client(harness, Arc::new(FailingResolver), test_config());
600
601        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
602        assert!(matches!(err, Error::Resolution { .. }), "{err}");
603        assert!(err.is_retryable());
604    }
605
606    #[test(tokio::test)]
607    #[ntest::timeout(10_000)]
608    async fn establish_failure_reconnects_once_and_retries() {
609        let (router, hits) = test_router();
610        let harness = TestServerHarness::new(router);
611        let resolver = StaticResolver::new(vec![server_scion_ip()]);
612        let (client, _) = harness_client(harness.clone(), resolver, test_config());
613
614        let response = client.get(url("localhost", "/hello")).await.unwrap();
615        assert!(response.is_success());
616        assert_eq!(harness.binds.load(SeqCst), 1);
617
618        // Break the established connection by taking its socket away. The
619        // pooled client's own re-establishment (same, still broken socket)
620        // then fails, so the request path re-establishes through the pool
621        // (fresh socket, fresh server) and retries — invisible to the caller.
622        harness.break_sockets();
623        // Long enough for the client to notice the broken socket, which takes
624        // a couple of task wake-ups rather than any timeout.
625        tokio::time::sleep(Duration::from_millis(200)).await;
626
627        let response = client.get(url("localhost", "/hello")).await.unwrap();
628        assert!(response.is_success());
629        assert_eq!(harness.binds.load(SeqCst), 2);
630        assert_eq!(hits.load(SeqCst), 2);
631    }
632
633    #[test(tokio::test)]
634    #[ntest::timeout(10_000)]
635    async fn stream_level_errors_are_not_retried() {
636        let (router, _) = test_router();
637        // A server that allows zero concurrent request streams: the request
638        // fails at the stream level, after establishment.
639        let mut server_quic = QuicConfig::builder().verify_peer(false).build();
640        server_quic.initial_max_streams_bidi = 0;
641        let harness = TestServerHarness::with_server_quic(router, server_quic);
642        let resolver = StaticResolver::new(vec![server_scion_ip()]);
643        let (client, _) = harness_client(harness.clone(), resolver, test_config());
644
645        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
646        assert!(matches!(err, Error::ConnectionLimit), "{err}");
647        // No re-establishment was attempted: stream-level failures are not
648        // establishment failures, so the establish-retry must not fire.
649        assert_eq!(harness.binds.load(SeqCst), 1);
650    }
651
652    #[test(tokio::test)]
653    #[ntest::timeout(10_000)]
654    async fn reset_is_lazy_and_rebuild_is_single_flight() {
655        let (router, _) = test_router();
656        let harness = TestServerHarness::new(router);
657        let resolver = StaticResolver::new(vec![server_scion_ip()]);
658        let (client, builds) = harness_client(harness, resolver, test_config());
659        let client = Arc::new(client);
660
661        client.get(url("localhost", "/hello")).await.unwrap();
662        assert_eq!(builds.load(SeqCst), 1);
663
664        // Reset alone does nothing: the rebuild happens on the next request.
665        client.reset();
666        assert_eq!(builds.load(SeqCst), 1);
667
668        // Concurrent requests after a reset trigger exactly one rebuild: the
669        // write lock is the single-flight.
670        let tasks: Vec<_> = (0..3)
671            .map(|_| {
672                let client = client.clone();
673                tokio::spawn(async move { client.get(url("localhost", "/hello")).await })
674            })
675            .collect();
676        for task in tasks {
677            task.await.unwrap().unwrap();
678        }
679        assert_eq!(builds.load(SeqCst), 2);
680    }
681
682    #[test(tokio::test)]
683    #[ntest::timeout(10_000)]
684    async fn reset_during_rebuild_triggers_another_rebuild() {
685        let (router, _) = test_router();
686        let harness = TestServerHarness::new(router);
687        let resolver = StaticResolver::new(vec![server_scion_ip()]);
688        let builds = Arc::new(AtomicUsize::new(0));
689        // Every build consumes one permit, so the test controls when a
690        // rebuild may complete.
691        let gate = Arc::new(tokio::sync::Semaphore::new(1));
692        let factory_builds = builds.clone();
693        let factory_gate = gate.clone();
694        let config = test_config();
695        let factory_config = config.clone();
696        let client = Arc::new(Client::with_epoch_factory(
697            config,
698            Box::new(move || {
699                let builds = factory_builds.clone();
700                let gate = factory_gate.clone();
701                let harness = harness.clone();
702                let resolver = resolver.clone();
703                let config = factory_config.clone();
704                Box::pin(async move {
705                    gate.acquire().await.expect("gate closed").forget();
706                    builds.fetch_add(1, SeqCst);
707                    Ok(Epoch::new(harness, resolver, &config))
708                })
709            }),
710        ));
711
712        client.get(url("localhost", "/hello")).await.unwrap();
713        assert_eq!(builds.load(SeqCst), 1);
714
715        // Start a rebuild and hold it open at the gate...
716        client.reset();
717        let pending = {
718            let client = client.clone();
719            tokio::spawn(async move { client.get(url("localhost", "/hello")).await })
720        };
721        tokio::time::sleep(Duration::from_millis(100)).await;
722        assert_eq!(builds.load(SeqCst), 1);
723
724        // ...then reset again while the rebuild is running (the network
725        // changed again mid-build), and let the rebuild finish.
726        client.reset();
727        gate.add_permits(2);
728        pending.await.unwrap().unwrap();
729        assert_eq!(builds.load(SeqCst), 2);
730
731        // The mid-build reset must not be swallowed: the next request
732        // rebuilds again instead of trusting the epoch built against the
733        // network that was replaced under it.
734        client.get(url("localhost", "/hello")).await.unwrap();
735        assert_eq!(builds.load(SeqCst), 3);
736    }
737
738    #[test(tokio::test)]
739    #[ntest::timeout(10_000)]
740    async fn failed_rebuild_keeps_stale_and_retries_next_request() {
741        let (router, _) = test_router();
742        let harness = TestServerHarness::new(router);
743        let resolver = StaticResolver::new(vec![server_scion_ip()]);
744        let builds = Arc::new(AtomicUsize::new(0));
745        let factory_builds = builds.clone();
746        let config = test_config();
747        let factory_config = config.clone();
748        let client = Client::with_epoch_factory(
749            config,
750            Box::new(move || {
751                let build = factory_builds.fetch_add(1, SeqCst) + 1;
752                let harness = harness.clone();
753                let resolver = resolver.clone();
754                let config = factory_config.clone();
755                Box::pin(async move {
756                    if build == 2 {
757                        return Err(Error::StackBuild {
758                            retryable: true,
759                            source: "flaky discovery".into(),
760                        });
761                    }
762                    Ok(Epoch::new(harness, resolver, &config))
763                })
764            }),
765        );
766
767        client.get(url("localhost", "/hello")).await.unwrap();
768        client.reset();
769
770        // The rebuild fails: the request reports the build error...
771        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
772        assert!(matches!(err, Error::StackBuild { .. }), "{err}");
773        assert!(err.is_retryable());
774
775        // ...and the next request retries the rebuild and succeeds.
776        client.get(url("localhost", "/hello")).await.unwrap();
777        assert_eq!(builds.load(SeqCst), 3);
778    }
779
780    #[test(tokio::test)]
781    #[ntest::timeout(10_000)]
782    async fn epochs_do_not_share_stacks_across_reset() {
783        // After a reset, sockets come from the new epoch's binder only.
784        let (router_one, _) = test_router();
785        let (router_two, _) = test_router();
786        let first = TestServerHarness::new(router_one);
787        let second = TestServerHarness::new(router_two);
788        let resolver = StaticResolver::new(vec![server_scion_ip()]);
789        let builds = Arc::new(AtomicUsize::new(0));
790        let factory_builds = builds.clone();
791        let config = test_config();
792        let factory_config = config.clone();
793        let factory_first = first.clone();
794        let factory_second = second.clone();
795        let client = Client::with_epoch_factory(
796            config,
797            Box::new(move || {
798                let build = factory_builds.fetch_add(1, SeqCst) + 1;
799                let harness = if build == 1 {
800                    factory_first.clone()
801                } else {
802                    factory_second.clone()
803                };
804                let resolver = resolver.clone();
805                let config = factory_config.clone();
806                Box::pin(async move { Ok(Epoch::new(harness, resolver, &config)) })
807            }),
808        );
809
810        client.get(url("localhost", "/hello")).await.unwrap();
811        assert_eq!(
812            (first.binds.load(SeqCst), second.binds.load(SeqCst)),
813            (1, 0)
814        );
815
816        client.reset();
817        client.get(url("localhost", "/hello")).await.unwrap();
818        assert_eq!(
819            (first.binds.load(SeqCst), second.binds.load(SeqCst)),
820            (1, 1)
821        );
822    }
823
824    #[test(tokio::test)]
825    #[ntest::timeout(10_000)]
826    async fn stalled_establishment_does_not_block_other_origins() {
827        // One origin stuck in establishment holds only its own
828        // single-flight; other origins proceed.
829        let (router, _) = test_router();
830        let harness = TestServerHarness::new(router);
831        harness.stall_first_bind();
832        let resolver = StaticResolver::new(vec![server_scion_ip()]);
833        let (client, _) = harness_client(harness.clone(), resolver, test_config());
834        let client = Arc::new(client);
835
836        let stalled = {
837            let client = client.clone();
838            tokio::spawn(async move { client.get(url("a.local", "/hello")).await })
839        };
840        tokio::time::sleep(Duration::from_millis(100)).await;
841        assert!(!stalled.is_finished());
842
843        // A different origin (different host, same live server) completes
844        // while the first is still stalled.
845        let response = client.get(url("b.local", "/hello")).await.unwrap();
846        assert!(response.is_success());
847        assert!(!stalled.is_finished());
848
849        harness.release_stalled();
850        stalled.await.unwrap().unwrap();
851    }
852
853    #[test(tokio::test)]
854    #[ntest::timeout(10_000)]
855    async fn evicted_origin_completes_in_flight_request() {
856        // Removal is not destruction — an in-flight request keeps its
857        // client alive through the `Arc` and completes after eviction.
858        let (router, _) = test_router();
859        let harness = TestServerHarness::new(router);
860        let resolver = StaticResolver::new(vec![server_scion_ip()]);
861        let (client, _) = harness_client(harness, resolver, test_config().with_max_origins(1));
862        let client = Arc::new(client);
863
864        let slow = {
865            let client = client.clone();
866            tokio::spawn(async move {
867                let response = client.get(url("a.local", "/slow")).await?;
868                response.text(Some(1024)).await
869            })
870        };
871        tokio::time::sleep(Duration::from_millis(150)).await;
872
873        // This inserts a second origin into a map capped at one, evicting the
874        // origin with the in-flight request.
875        client.get(url("b.local", "/hello")).await.unwrap();
876
877        let (body, _) = slow.await.unwrap().unwrap();
878        assert_eq!(body, "slow");
879    }
880
881    #[test(tokio::test)]
882    #[ntest::timeout(10_000)]
883    async fn close_rejects_new_requests_and_faults_in_flight() {
884        let (router, _) = test_router();
885        let harness = TestServerHarness::new(router);
886        let resolver = StaticResolver::new(vec![server_scion_ip()]);
887        let (client, _) = harness_client(harness, resolver, test_config());
888        let client = Arc::new(client);
889
890        client.get(url("localhost", "/hello")).await.unwrap();
891
892        // An in-flight request faults promptly on close instead of running to
893        // completion or timing out.
894        let in_flight = {
895            let client = client.clone();
896            tokio::spawn(async move {
897                let response = client.get(url("localhost", "/slow")).await?;
898                response.text(Some(1024)).await
899            })
900        };
901        tokio::time::sleep(Duration::from_millis(100)).await;
902        client.close().await;
903
904        let start = tokio::time::Instant::now();
905        let result = in_flight.await.unwrap();
906        assert!(result.is_err());
907        assert!(start.elapsed() < Duration::from_millis(300));
908
909        // The client is permanently closed; closing again is a no-op.
910        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
911        assert!(matches!(err, Error::Closed), "{err}");
912        client.close().await;
913    }
914
915    #[test(tokio::test)]
916    #[ntest::timeout(10_000)]
917    async fn per_request_timeout_overrides_the_default() {
918        let (router, _) = test_router();
919        let harness = TestServerHarness::new(router);
920        let resolver = StaticResolver::new(vec![server_scion_ip()]);
921        let (client, _) = harness_client(harness, resolver, test_config());
922
923        let request = Request::get(url("localhost", "/slow"))
924            .request_timeout(Duration::from_millis(100))
925            .build()
926            .unwrap();
927        let err = client.request(request).await.err().unwrap();
928        assert!(
929            matches!(
930                err,
931                Error::Timeout {
932                    phase: TimeoutPhase::Request,
933                    ..
934                }
935            ),
936            "{err}"
937        );
938    }
939
940    #[test(tokio::test)]
941    #[ntest::timeout(10_000)]
942    async fn body_collection_respects_max_size() {
943        let (router, _) = test_router();
944        let harness = TestServerHarness::new(router);
945        let resolver = StaticResolver::new(vec![server_scion_ip()]);
946        let (client, _) = harness_client(harness, resolver, test_config());
947
948        let response = client.get(url("localhost", "/hello")).await.unwrap();
949        let err = response.bytes(Some(2)).await.err().unwrap();
950        assert!(matches!(err, Error::BodyTooLarge { limit: 2 }), "{err}");
951    }
952
953    #[test(tokio::test)]
954    #[ntest::timeout(10_000)]
955    async fn warm_up_establishes_without_a_request() {
956        let (router, hits) = test_router();
957        let harness = TestServerHarness::new(router);
958        let resolver = StaticResolver::new(vec![server_scion_ip()]);
959        let (client, builds) = harness_client(harness.clone(), resolver, test_config());
960
961        client.warm_up(url("localhost", "/")).await.unwrap();
962        assert_eq!(builds.load(SeqCst), 1);
963        assert_eq!(harness.binds.load(SeqCst), 1);
964        assert_eq!(hits.load(SeqCst), 0);
965
966        // The warmed-up connection is the one the request uses.
967        client.get(url("localhost", "/hello")).await.unwrap();
968        assert_eq!(harness.binds.load(SeqCst), 1);
969        assert_eq!(hits.load(SeqCst), 1);
970    }
971}