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::{H3ResponseBody, Http3Client, RequestError as H3RequestError};
27
28use crate::{
29    config::Config,
30    epoch::Epoch,
31    error::{Error, TimeoutPhase},
32    origin::Origin,
33    request::{IntoUrl, Request},
34    response::Response,
35};
36
37/// The current connectivity, or the reason there is none.
38enum EpochSlot {
39    /// No connectivity has been built yet (construction does no I/O; the
40    /// first request builds it).
41    Init,
42    /// Active connectivity, tagged with the reset generation it was built
43    /// at: the epoch is current only while the tag matches
44    /// [`Client::reset_generation`].
45    Active(Arc<Epoch>, u64),
46    /// [`Client::close`] was called; the client is permanently closed.
47    Closed,
48}
49
50/// A high-level HTTP/3-over-SCION client: URL in, response out.
51///
52/// The client owns everything between a URL and a connection: SCION
53/// connectivity, DNS resolution, a connection pool, and timeouts. It is cheap
54/// to construct and intended to be a long-lived value shared across an
55/// application: it is `Send + Sync`, and requests borrow it immutably.
56///
57/// ```no_run
58/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
59/// use scion_http3::{Client, Config};
60///
61/// const MAX_BODY_SIZE: usize = 1 << 20;
62///
63/// let client = Client::new(Config::new("https://endhost-api.example.org".parse()?));
64/// let response = client.get("https://chat.example.org/rooms").await?;
65/// let (body, _trailers) = response.text(Some(MAX_BODY_SIZE)).await?;
66/// # Ok(())
67/// # }
68/// ```
69pub struct Client {
70    /// The current epoch, or the reason there is none.
71    epoch: tokio::sync::RwLock<EpochSlot>,
72    /// Bumped by [`reset`](Self::reset); the active epoch carries the value
73    /// it was built at and a mismatch marks it stale. It's a counter rather than
74    /// a flag so a reset that arrives while a rebuild runs is not lost:
75    /// the rebuild tags its epoch with a pre-build snapshot, which the reset
76    /// has already moved past.
77    reset_generation: AtomicU64,
78    /// The client configuration.
79    config: Config,
80    /// Test only: replaces [`Epoch::build`] so epoch lifecycle is testable
81    /// without a real stack.
82    #[cfg(test)]
83    epoch_factory: Option<EpochFactory>,
84}
85
86#[cfg(test)]
87type EpochFactory =
88    Box<dyn Fn() -> futures::future::BoxFuture<'static, Result<Epoch, Error>> + Send + Sync>;
89
90impl Client {
91    /// Creates a client. Performs no I/O: connectivity is built lazily by the
92    /// first request (or by [`warm_up`](Self::warm_up)).
93    #[must_use]
94    pub fn new(config: Config) -> Self {
95        Client {
96            epoch: tokio::sync::RwLock::new(EpochSlot::Init),
97            reset_generation: AtomicU64::new(0),
98            config,
99            #[cfg(test)]
100            epoch_factory: None,
101        }
102    }
103
104    /// Creates a client whose epochs come from `factory` instead of
105    /// [`Epoch::build`].
106    #[cfg(test)]
107    pub(crate) fn with_epoch_factory(config: Config, factory: EpochFactory) -> Self {
108        let mut client = Client::new(config);
109        client.epoch_factory = Some(factory);
110        client
111    }
112
113    /// Issues a request and returns the response once its head has arrived.
114    ///
115    /// The whole call, building connectivity if needed, resolving the
116    /// origin, establishing or reusing its connection, and waiting for the
117    /// response head, runs under the request timeout (the per-request
118    /// override, or the configured default). Collecting the body on the
119    /// returned [`Response`] runs under the remainder of the same deadline.
120    ///
121    /// If establishing a connection through a pooled client fails, the origin
122    /// is re-resolved and re-established once and the request re-issued. This
123    /// is safe for non-idempotent requests: establishment failure means
124    /// nothing reached the wire, and bodies are buffered and thus replayable.
125    /// Errors after the request reached the wire are never retried here; see
126    /// [`Error::is_retryable`] for what the caller may retry.
127    pub async fn request(&self, request: Request) -> Result<Response, Error> {
128        let timeout = request
129            .request_timeout()
130            .unwrap_or(self.config.request_timeout);
131        let now = tokio::time::Instant::now();
132        // A timeout too large to represent is effectively "no deadline"; 30
133        // years stands in for it (mirroring tokio's internal far_future).
134        let deadline = now
135            .checked_add(timeout)
136            .unwrap_or_else(|| now + Duration::from_secs(86400 * 365 * 30));
137        let (response, connection) = tokio::time::timeout_at(deadline, self.request_head(&request))
138            .await
139            .map_err(|_| {
140                Error::Timeout {
141                    phase: TimeoutPhase::Request,
142                    timeout,
143                }
144            })??;
145        Ok(Response::new(response, deadline, timeout, connection))
146    }
147
148    /// Issues a GET request to `url`. Shorthand for building a [`Request`]
149    /// with defaults and calling [`request`](Self::request).
150    pub async fn get(&self, url: impl IntoUrl) -> Result<Response, Error> {
151        let request = Request::get(url).build()?;
152        self.request(request).await
153    }
154
155    /// Issues a POST request with the given body to `url`. Shorthand for
156    /// building a [`Request`] with defaults and calling
157    /// [`request`](Self::request).
158    pub async fn post(&self, url: impl IntoUrl, body: impl Into<Bytes>) -> Result<Response, Error> {
159        let request = Request::post(url).body(body).build()?;
160        self.request(request).await
161    }
162
163    /// Pre-establishes connectivity to `url`'s origin: builds the stack if
164    /// needed, resolves the origin, and performs the QUIC handshake, so the
165    /// first request pays none of it. Runs under the connect timeout (plus
166    /// stack build time).
167    pub async fn warm_up(&self, url: impl IntoUrl) -> Result<(), Error> {
168        let request = Request::get(url).build()?;
169        let origin = Origin::from_request(&request)?;
170        let epoch = self.current_epoch().await?;
171        let origin_client = epoch.origin_client(origin, Instant::now(), &self.config)?;
172        origin_client.connection().await?;
173        Ok(())
174    }
175
176    /// Marks connectivity stale: the next request tears down the current
177    /// connections and rebuilds the stack, discovery, and resolver state.
178    ///
179    /// Call this when the network below the client changed (for example on a
180    /// platform network-change signal). Never blocks; if the rebuild fails,
181    /// the failing request reports the build error and the next request
182    /// retries the rebuild.
183    pub fn reset(&self) {
184        self.reset_generation.fetch_add(1, Ordering::Release);
185    }
186
187    /// Closes the client: tears down all pooled connections, faulting
188    /// in-flight requests promptly. Subsequent requests fail with
189    /// [`Error::Closed`]. Idempotent.
190    pub async fn close(&self) {
191        let previous = {
192            let mut slot = self.epoch.write().await;
193            std::mem::replace(&mut *slot, EpochSlot::Closed)
194        };
195        if let EpochSlot::Active(epoch, _) = previous {
196            epoch.shutdown().await;
197        }
198    }
199
200    /// The request path up to the response head. The request timeout is
201    /// applied around this by [`request`](Self::request). Returns the
202    /// transport client alongside the response so the [`Response`] can keep
203    /// the connection alive while the body streams.
204    async fn request_head(
205        &self,
206        request: &Request,
207    ) -> Result<(http::Response<H3ResponseBody>, Arc<Http3Client>), Error> {
208        let origin = Origin::from_request(request)?;
209        let (host, port) = (origin.host.clone(), origin.port);
210        let epoch = self.current_epoch().await?;
211        let origin_client = epoch.origin_client(origin, Instant::now(), &self.config)?;
212
213        let (connection, generation) = origin_client.connection().await?;
214        match connection.request(request.to_http()?).await {
215            Ok(response) => Ok((response, connection)),
216            // Establishment failure means nothing reached the wire, so one
217            // re-establish (possibly to a different candidate) and retry is
218            // safe even for non-idempotent requests.
219            Err(H3RequestError::Establish(_)) => {
220                let (connection, _) = origin_client.reconnect(generation).await?;
221                let response = connection
222                    .request(request.to_http()?)
223                    .await
224                    .map_err(|e| Error::from_h3_request_error(&host, port, e))?;
225                Ok((response, connection))
226            }
227            Err(e) => Err(Error::from_h3_request_error(&host, port, e)),
228        }
229    }
230
231    /// Returns the current epoch, building or rebuilding it if needed.
232    ///
233    /// Fast path: a read lock and a generation check. Slow path: the write
234    /// lock serializes rebuilds (concurrent requests block here, then take
235    /// the re-check and adopt the fresh epoch). On rebuild failure the old
236    /// epoch stays in place with its stale tag: requests fail fast with the
237    /// build error and the next one retries the rebuild.
238    async fn current_epoch(&self) -> Result<Arc<Epoch>, Error> {
239        {
240            let slot = self.epoch.read().await;
241            match &*slot {
242                EpochSlot::Closed => return Err(Error::Closed),
243                EpochSlot::Active(epoch, built)
244                    if *built == self.reset_generation.load(Ordering::Acquire) =>
245                {
246                    return Ok(epoch.clone());
247                }
248                _ => {}
249            }
250        }
251
252        let mut slot = self.epoch.write().await;
253        match &*slot {
254            EpochSlot::Closed => Err(Error::Closed),
255            EpochSlot::Active(epoch, built)
256                if *built == self.reset_generation.load(Ordering::Acquire) =>
257            {
258                Ok(epoch.clone())
259            }
260            _ => {
261                // Snapshot before building: a reset that arrives while the
262                // build runs leaves the counter ahead of this snapshot, so
263                // the next request rebuilds again instead of trusting an
264                // epoch built against the network that was just replaced.
265                let generation = self.reset_generation.load(Ordering::Acquire);
266                let fresh = Arc::new(self.build_epoch().await?);
267                let previous =
268                    std::mem::replace(&mut *slot, EpochSlot::Active(fresh.clone(), generation));
269                if let EpochSlot::Active(previous, _) = previous {
270                    // Best effort, off the lock: in-flight requests on the old
271                    // epoch are faulted promptly by the shutdown's close().
272                    tracing::debug!("Replacing SCION connectivity epoch");
273                    tokio::spawn(previous.shutdown());
274                }
275                Ok(fresh)
276            }
277        }
278    }
279
280    async fn build_epoch(&self) -> Result<Epoch, Error> {
281        #[cfg(test)]
282        if let Some(factory) = &self.epoch_factory {
283            return factory().await;
284        }
285        Epoch::build(&self.config).await
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use std::{
292        sync::atomic::{AtomicUsize, Ordering::SeqCst},
293        time::Duration,
294    };
295
296    use scion_quic::quic::config::QuicConfig;
297    use test_log::test;
298
299    use super::*;
300    use crate::test_support::{
301        FailingResolver, SERVER_PORT, StaticResolver, TestServerHarness, server_scion_ip,
302        test_config, test_router,
303    };
304
305    /// A client whose epochs are assembled from the given harness/resolver
306    /// instead of a real stack; `builds` counts epoch (re)builds.
307    fn harness_client(
308        harness: Arc<TestServerHarness>,
309        resolver: Arc<dyn scion_stack::resolver::ScionDnsResolver>,
310        config: Config,
311    ) -> (Client, Arc<AtomicUsize>) {
312        let builds = Arc::new(AtomicUsize::new(0));
313        let factory_builds = builds.clone();
314        let factory_config = config.clone();
315        let client = Client::with_epoch_factory(
316            config,
317            Box::new(move || {
318                factory_builds.fetch_add(1, SeqCst);
319                let harness = harness.clone();
320                let resolver = resolver.clone();
321                let config = factory_config.clone();
322                Box::pin(async move { Ok(Epoch::new(harness, resolver, &config)) })
323            }),
324        );
325        (client, builds)
326    }
327
328    fn url(host: &str, path: &str) -> String {
329        format!("https://{host}:{SERVER_PORT}{path}")
330    }
331
332    #[test(tokio::test)]
333    #[ntest::timeout(10_000)]
334    async fn get_resolves_via_dns_path() {
335        let (router, hits) = test_router();
336        let harness = TestServerHarness::new(router);
337        let resolver = StaticResolver::new(vec![server_scion_ip()]);
338        let (client, _) = harness_client(harness, resolver, test_config());
339
340        let response = client.get(url("localhost", "/hello")).await.unwrap();
341        assert!(response.is_success());
342        let (body, _) = response.text(Some(1024)).await.unwrap();
343        assert_eq!(body, "world");
344        assert_eq!(hits.load(SeqCst), 1);
345    }
346
347    #[test(tokio::test)]
348    #[ntest::timeout(10_000)]
349    async fn duration_max_timeout_does_not_panic() {
350        let (router, _) = test_router();
351        let harness = TestServerHarness::new(router);
352        let resolver = StaticResolver::new(vec![server_scion_ip()]);
353        let (client, _) = harness_client(harness, resolver, test_config());
354
355        let request = Request::get(url("localhost", "/hello"))
356            .request_timeout(Duration::MAX)
357            .build()
358            .unwrap();
359        let response = client.request(request).await.unwrap();
360        assert!(response.is_success());
361    }
362
363    #[test(tokio::test)]
364    #[ntest::timeout(10_000)]
365    async fn target_bypasses_the_resolver() {
366        let (router, _) = test_router();
367        let harness = TestServerHarness::new(router);
368        let (client, _) = harness_client(harness, Arc::new(FailingResolver), test_config());
369
370        let request = Request::post(url("localhost", "/echo"))
371            .body("ping")
372            .target(server_scion_ip())
373            .build()
374            .unwrap();
375        let response = client.request(request).await.unwrap();
376        let (body, _) = response.text(Some(1024)).await.unwrap();
377        assert_eq!(body, "ping");
378    }
379
380    #[test(tokio::test)]
381    #[ntest::timeout(10_000)]
382    async fn resolution_failure_surfaces_as_retryable_resolution_error() {
383        let (router, _) = test_router();
384        let harness = TestServerHarness::new(router);
385        let (client, _) = harness_client(harness, Arc::new(FailingResolver), test_config());
386
387        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
388        assert!(matches!(err, Error::Resolution { .. }), "{err}");
389        assert!(err.is_retryable());
390    }
391
392    #[test(tokio::test)]
393    #[ntest::timeout(10_000)]
394    async fn establish_failure_reconnects_once_and_retries() {
395        let (router, hits) = test_router();
396        let harness = TestServerHarness::new(router);
397        let resolver = StaticResolver::new(vec![server_scion_ip()]);
398        let (client, _) = harness_client(harness.clone(), resolver, test_config());
399
400        let response = client.get(url("localhost", "/hello")).await.unwrap();
401        assert!(response.is_success());
402        assert_eq!(harness.binds.load(SeqCst), 1);
403
404        // Break the established connection by taking its socket away. The
405        // pooled client's own re-establishment (same, still broken socket)
406        // then fails, so the request path re-establishes through the pool
407        // (fresh socket, fresh server) and retries — invisible to the caller.
408        harness.break_sockets();
409        // Long enough for the client to notice the broken socket, which takes
410        // a couple of task wake-ups rather than any timeout.
411        tokio::time::sleep(Duration::from_millis(200)).await;
412
413        let response = client.get(url("localhost", "/hello")).await.unwrap();
414        assert!(response.is_success());
415        assert_eq!(harness.binds.load(SeqCst), 2);
416        assert_eq!(hits.load(SeqCst), 2);
417    }
418
419    #[test(tokio::test)]
420    #[ntest::timeout(10_000)]
421    async fn stream_level_errors_are_not_retried() {
422        let (router, _) = test_router();
423        // A server that allows zero concurrent request streams: the request
424        // fails at the stream level, after establishment.
425        let mut server_quic = QuicConfig::builder().verify_peer(false).build();
426        server_quic.initial_max_streams_bidi = 0;
427        let harness = TestServerHarness::with_server_quic(router, server_quic);
428        let resolver = StaticResolver::new(vec![server_scion_ip()]);
429        let (client, _) = harness_client(harness.clone(), resolver, test_config());
430
431        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
432        assert!(matches!(err, Error::ConnectionLimit), "{err}");
433        // No re-establishment was attempted: stream-level failures are not
434        // establishment failures, so the establish-retry must not fire.
435        assert_eq!(harness.binds.load(SeqCst), 1);
436    }
437
438    #[test(tokio::test)]
439    #[ntest::timeout(10_000)]
440    async fn reset_is_lazy_and_rebuild_is_single_flight() {
441        let (router, _) = test_router();
442        let harness = TestServerHarness::new(router);
443        let resolver = StaticResolver::new(vec![server_scion_ip()]);
444        let (client, builds) = harness_client(harness, resolver, test_config());
445        let client = Arc::new(client);
446
447        client.get(url("localhost", "/hello")).await.unwrap();
448        assert_eq!(builds.load(SeqCst), 1);
449
450        // Reset alone does nothing: the rebuild happens on the next request.
451        client.reset();
452        assert_eq!(builds.load(SeqCst), 1);
453
454        // Concurrent requests after a reset trigger exactly one rebuild: the
455        // write lock is the single-flight.
456        let tasks: Vec<_> = (0..3)
457            .map(|_| {
458                let client = client.clone();
459                tokio::spawn(async move { client.get(url("localhost", "/hello")).await })
460            })
461            .collect();
462        for task in tasks {
463            task.await.unwrap().unwrap();
464        }
465        assert_eq!(builds.load(SeqCst), 2);
466    }
467
468    #[test(tokio::test)]
469    #[ntest::timeout(10_000)]
470    async fn reset_during_rebuild_triggers_another_rebuild() {
471        let (router, _) = test_router();
472        let harness = TestServerHarness::new(router);
473        let resolver = StaticResolver::new(vec![server_scion_ip()]);
474        let builds = Arc::new(AtomicUsize::new(0));
475        // Every build consumes one permit, so the test controls when a
476        // rebuild may complete.
477        let gate = Arc::new(tokio::sync::Semaphore::new(1));
478        let factory_builds = builds.clone();
479        let factory_gate = gate.clone();
480        let config = test_config();
481        let factory_config = config.clone();
482        let client = Arc::new(Client::with_epoch_factory(
483            config,
484            Box::new(move || {
485                let builds = factory_builds.clone();
486                let gate = factory_gate.clone();
487                let harness = harness.clone();
488                let resolver = resolver.clone();
489                let config = factory_config.clone();
490                Box::pin(async move {
491                    gate.acquire().await.expect("gate closed").forget();
492                    builds.fetch_add(1, SeqCst);
493                    Ok(Epoch::new(harness, resolver, &config))
494                })
495            }),
496        ));
497
498        client.get(url("localhost", "/hello")).await.unwrap();
499        assert_eq!(builds.load(SeqCst), 1);
500
501        // Start a rebuild and hold it open at the gate...
502        client.reset();
503        let pending = {
504            let client = client.clone();
505            tokio::spawn(async move { client.get(url("localhost", "/hello")).await })
506        };
507        tokio::time::sleep(Duration::from_millis(100)).await;
508        assert_eq!(builds.load(SeqCst), 1);
509
510        // ...then reset again while the rebuild is running (the network
511        // changed again mid-build), and let the rebuild finish.
512        client.reset();
513        gate.add_permits(2);
514        pending.await.unwrap().unwrap();
515        assert_eq!(builds.load(SeqCst), 2);
516
517        // The mid-build reset must not be swallowed: the next request
518        // rebuilds again instead of trusting the epoch built against the
519        // network that was replaced under it.
520        client.get(url("localhost", "/hello")).await.unwrap();
521        assert_eq!(builds.load(SeqCst), 3);
522    }
523
524    #[test(tokio::test)]
525    #[ntest::timeout(10_000)]
526    async fn failed_rebuild_keeps_stale_and_retries_next_request() {
527        let (router, _) = test_router();
528        let harness = TestServerHarness::new(router);
529        let resolver = StaticResolver::new(vec![server_scion_ip()]);
530        let builds = Arc::new(AtomicUsize::new(0));
531        let factory_builds = builds.clone();
532        let config = test_config();
533        let factory_config = config.clone();
534        let client = Client::with_epoch_factory(
535            config,
536            Box::new(move || {
537                let build = factory_builds.fetch_add(1, SeqCst) + 1;
538                let harness = harness.clone();
539                let resolver = resolver.clone();
540                let config = factory_config.clone();
541                Box::pin(async move {
542                    if build == 2 {
543                        return Err(Error::StackBuild {
544                            retryable: true,
545                            source: "flaky discovery".into(),
546                        });
547                    }
548                    Ok(Epoch::new(harness, resolver, &config))
549                })
550            }),
551        );
552
553        client.get(url("localhost", "/hello")).await.unwrap();
554        client.reset();
555
556        // The rebuild fails: the request reports the build error...
557        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
558        assert!(matches!(err, Error::StackBuild { .. }), "{err}");
559        assert!(err.is_retryable());
560
561        // ...and the next request retries the rebuild and succeeds.
562        client.get(url("localhost", "/hello")).await.unwrap();
563        assert_eq!(builds.load(SeqCst), 3);
564    }
565
566    #[test(tokio::test)]
567    #[ntest::timeout(10_000)]
568    async fn epochs_do_not_share_stacks_across_reset() {
569        // After a reset, sockets come from the new epoch's binder only.
570        let (router_one, _) = test_router();
571        let (router_two, _) = test_router();
572        let first = TestServerHarness::new(router_one);
573        let second = TestServerHarness::new(router_two);
574        let resolver = StaticResolver::new(vec![server_scion_ip()]);
575        let builds = Arc::new(AtomicUsize::new(0));
576        let factory_builds = builds.clone();
577        let config = test_config();
578        let factory_config = config.clone();
579        let factory_first = first.clone();
580        let factory_second = second.clone();
581        let client = Client::with_epoch_factory(
582            config,
583            Box::new(move || {
584                let build = factory_builds.fetch_add(1, SeqCst) + 1;
585                let harness = if build == 1 {
586                    factory_first.clone()
587                } else {
588                    factory_second.clone()
589                };
590                let resolver = resolver.clone();
591                let config = factory_config.clone();
592                Box::pin(async move { Ok(Epoch::new(harness, resolver, &config)) })
593            }),
594        );
595
596        client.get(url("localhost", "/hello")).await.unwrap();
597        assert_eq!(
598            (first.binds.load(SeqCst), second.binds.load(SeqCst)),
599            (1, 0)
600        );
601
602        client.reset();
603        client.get(url("localhost", "/hello")).await.unwrap();
604        assert_eq!(
605            (first.binds.load(SeqCst), second.binds.load(SeqCst)),
606            (1, 1)
607        );
608    }
609
610    #[test(tokio::test)]
611    #[ntest::timeout(10_000)]
612    async fn stalled_establishment_does_not_block_other_origins() {
613        // One origin stuck in establishment holds only its own
614        // single-flight; other origins proceed.
615        let (router, _) = test_router();
616        let harness = TestServerHarness::new(router);
617        harness.stall_first_bind();
618        let resolver = StaticResolver::new(vec![server_scion_ip()]);
619        let (client, _) = harness_client(harness.clone(), resolver, test_config());
620        let client = Arc::new(client);
621
622        let stalled = {
623            let client = client.clone();
624            tokio::spawn(async move { client.get(url("a.local", "/hello")).await })
625        };
626        tokio::time::sleep(Duration::from_millis(100)).await;
627        assert!(!stalled.is_finished());
628
629        // A different origin (different host, same live server) completes
630        // while the first is still stalled.
631        let response = client.get(url("b.local", "/hello")).await.unwrap();
632        assert!(response.is_success());
633        assert!(!stalled.is_finished());
634
635        harness.release_stalled();
636        stalled.await.unwrap().unwrap();
637    }
638
639    #[test(tokio::test)]
640    #[ntest::timeout(10_000)]
641    async fn evicted_origin_completes_in_flight_request() {
642        // Removal is not destruction — an in-flight request keeps its
643        // client alive through the `Arc` and completes after eviction.
644        let (router, _) = test_router();
645        let harness = TestServerHarness::new(router);
646        let resolver = StaticResolver::new(vec![server_scion_ip()]);
647        let (client, _) = harness_client(harness, resolver, test_config().with_max_origins(1));
648        let client = Arc::new(client);
649
650        let slow = {
651            let client = client.clone();
652            tokio::spawn(async move {
653                let response = client.get(url("a.local", "/slow")).await?;
654                response.text(Some(1024)).await
655            })
656        };
657        tokio::time::sleep(Duration::from_millis(150)).await;
658
659        // This inserts a second origin into a map capped at one, evicting the
660        // origin with the in-flight request.
661        client.get(url("b.local", "/hello")).await.unwrap();
662
663        let (body, _) = slow.await.unwrap().unwrap();
664        assert_eq!(body, "slow");
665    }
666
667    #[test(tokio::test)]
668    #[ntest::timeout(10_000)]
669    async fn close_rejects_new_requests_and_faults_in_flight() {
670        let (router, _) = test_router();
671        let harness = TestServerHarness::new(router);
672        let resolver = StaticResolver::new(vec![server_scion_ip()]);
673        let (client, _) = harness_client(harness, resolver, test_config());
674        let client = Arc::new(client);
675
676        client.get(url("localhost", "/hello")).await.unwrap();
677
678        // An in-flight request faults promptly on close instead of running to
679        // completion or timing out.
680        let in_flight = {
681            let client = client.clone();
682            tokio::spawn(async move {
683                let response = client.get(url("localhost", "/slow")).await?;
684                response.text(Some(1024)).await
685            })
686        };
687        tokio::time::sleep(Duration::from_millis(100)).await;
688        client.close().await;
689
690        let start = tokio::time::Instant::now();
691        let result = in_flight.await.unwrap();
692        assert!(result.is_err());
693        assert!(start.elapsed() < Duration::from_millis(300));
694
695        // The client is permanently closed; closing again is a no-op.
696        let err = client.get(url("localhost", "/hello")).await.err().unwrap();
697        assert!(matches!(err, Error::Closed), "{err}");
698        client.close().await;
699    }
700
701    #[test(tokio::test)]
702    #[ntest::timeout(10_000)]
703    async fn per_request_timeout_overrides_the_default() {
704        let (router, _) = test_router();
705        let harness = TestServerHarness::new(router);
706        let resolver = StaticResolver::new(vec![server_scion_ip()]);
707        let (client, _) = harness_client(harness, resolver, test_config());
708
709        let request = Request::get(url("localhost", "/slow"))
710            .request_timeout(Duration::from_millis(100))
711            .build()
712            .unwrap();
713        let err = client.request(request).await.err().unwrap();
714        assert!(
715            matches!(
716                err,
717                Error::Timeout {
718                    phase: TimeoutPhase::Request,
719                    ..
720                }
721            ),
722            "{err}"
723        );
724    }
725
726    #[test(tokio::test)]
727    #[ntest::timeout(10_000)]
728    async fn body_collection_respects_max_size() {
729        let (router, _) = test_router();
730        let harness = TestServerHarness::new(router);
731        let resolver = StaticResolver::new(vec![server_scion_ip()]);
732        let (client, _) = harness_client(harness, resolver, test_config());
733
734        let response = client.get(url("localhost", "/hello")).await.unwrap();
735        let err = response.bytes(Some(2)).await.err().unwrap();
736        assert!(matches!(err, Error::BodyTooLarge { limit: 2 }), "{err}");
737    }
738
739    #[test(tokio::test)]
740    #[ntest::timeout(10_000)]
741    async fn warm_up_establishes_without_a_request() {
742        let (router, hits) = test_router();
743        let harness = TestServerHarness::new(router);
744        let resolver = StaticResolver::new(vec![server_scion_ip()]);
745        let (client, builds) = harness_client(harness.clone(), resolver, test_config());
746
747        client.warm_up(url("localhost", "/")).await.unwrap();
748        assert_eq!(builds.load(SeqCst), 1);
749        assert_eq!(harness.binds.load(SeqCst), 1);
750        assert_eq!(hits.load(SeqCst), 0);
751
752        // The warmed-up connection is the one the request uses.
753        client.get(url("localhost", "/hello")).await.unwrap();
754        assert_eq!(harness.binds.load(SeqCst), 1);
755        assert_eq!(hits.load(SeqCst), 1);
756    }
757}