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