scion_http3/config.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//! Client configuration.
16
17use std::{fmt, sync::Arc, time::Duration};
18
19use async_trait::async_trait;
20use scion_quic::quic::config::QuicConfig;
21use scion_stack::{
22 ScionStackBuilder,
23 reqwest_connect_rpc::token_source::{
24 TokenSource, TokenSourceError, TokenSourceWatch, static_token::StaticTokenSource,
25 },
26 resolver::ScionDnsResolver,
27 stack::builder::PreferredUnderlay,
28};
29use url::Url;
30
31/// Default timeout for establishing a connection to an origin.
32pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
33/// Default timeout for a request (response head plus body collection).
34pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
35/// Default idle time after which an origin's connection is swept.
36///
37/// Deliberately below the default QUIC idle timeout (30 seconds,
38/// [`scion_quic::quic::config::QuicConfig`]): QUIC kills an idle connection
39/// after that and keep-alives are off, so sweeping later would pool
40/// connections that are already dead.
41pub const DEFAULT_IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(25);
42/// Default bound on the number of distinct origins with pooled connections.
43pub const DEFAULT_MAX_ORIGINS: usize = 8;
44/// Default stagger between connection attempts to an origin's candidate
45/// addresses (RFC 8305, Happy Eyeballs).
46pub const DEFAULT_CONNECTION_ATTEMPT_DELAY: Duration = Duration::from_millis(250);
47
48/// The type of the [`ScionStackBuilder`] customization hook, see
49/// [`Config::with_stack_customizer`].
50pub(crate) type StackCustomizer =
51 dyn Fn(ScionStackBuilder) -> ScionStackBuilder + Send + Sync + 'static;
52
53/// Adapts a shared [`TokenSource`] to the by-value `impl TokenSource` the stack
54/// builder takes.
55///
56/// A [`Config`] is cloneable and outlives every stack built from it, so it holds
57/// the source behind an `Arc` and hands the builder one of these on each
58/// rebuild. Both methods delegate, so a refreshing source keeps refreshing.
59pub(crate) struct SharedTokenSource(pub(crate) Arc<dyn TokenSource>);
60
61#[async_trait]
62impl TokenSource for SharedTokenSource {
63 fn watch(&self) -> TokenSourceWatch {
64 self.0.watch()
65 }
66
67 async fn get_token(&self) -> Result<String, TokenSourceError> {
68 self.0.get_token().await
69 }
70}
71
72/// Configuration for a [`Client`](crate::Client).
73///
74/// Construct with [`Config::new`] and customize with the consuming `with_*`
75/// methods. The endhost API URL is the only required setting: it is how the
76/// client discovers the data planes available to it, and the same single
77/// setting reaches a real SCION network or a local `PocketSCION` topology.
78#[derive(Clone)]
79pub struct Config {
80 pub(crate) endhost_api: Url,
81 pub(crate) auth_token_source: Option<Arc<dyn TokenSource>>,
82 pub(crate) preferred_underlay: Option<PreferredUnderlay>,
83 pub(crate) stack_customizer: Option<Arc<StackCustomizer>>,
84 pub(crate) connect_timeout: Duration,
85 pub(crate) request_timeout: Duration,
86 pub(crate) idle_connection_timeout: Duration,
87 pub(crate) max_origins: usize,
88 pub(crate) connection_attempt_delay: Duration,
89 pub(crate) quic: QuicConfig,
90 pub(crate) resolver: Option<Arc<dyn ScionDnsResolver>>,
91}
92
93impl Config {
94 /// Creates a configuration that discovers connectivity through the given
95 /// endhost API.
96 #[must_use]
97 pub fn new(endhost_api: Url) -> Self {
98 Config {
99 endhost_api,
100 auth_token_source: None,
101 preferred_underlay: None,
102 stack_customizer: None,
103 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
104 request_timeout: DEFAULT_REQUEST_TIMEOUT,
105 idle_connection_timeout: DEFAULT_IDLE_CONNECTION_TIMEOUT,
106 max_origins: DEFAULT_MAX_ORIGINS,
107 connection_attempt_delay: DEFAULT_CONNECTION_ATTEMPT_DELAY,
108 quic: QuicConfig::default(),
109 resolver: None,
110 }
111 }
112
113 /// Sets a fixed token to authenticate with the endhost API and the SNAP
114 /// control plane.
115 ///
116 /// Use [`with_auth_token_source`](Self::with_auth_token_source) instead when
117 /// the token expires: connectivity is rebuilt on every
118 /// [`reset`](crate::Client::reset), and a token captured here is reused
119 /// verbatim each time.
120 #[must_use]
121 pub fn with_auth_token(self, token: impl Into<String>) -> Self {
122 self.with_auth_token_source(StaticTokenSource::from(token.into()))
123 }
124
125 /// Sets the token *source* used to authenticate with the endhost API and
126 /// the SNAP control plane.
127 ///
128 /// The source is shared, not rebuilt: the same one is handed to every stack
129 /// this configuration builds, so a refreshing source keeps its schedule and
130 /// its cached token across a [`reset`](crate::Client::reset). Implement
131 /// [`TokenSource`](scion_stack::reqwest_connect_rpc::token_source::TokenSource)
132 /// to supply tokens that expire.
133 ///
134 /// For a source scoped to the endhost API alone, reach the builder's
135 /// `with_endhost_api_auth_token_source` through
136 /// [`with_stack_customizer`](Self::with_stack_customizer).
137 #[must_use]
138 pub fn with_auth_token_source(mut self, source: impl TokenSource) -> Self {
139 self.auth_token_source = Some(Arc::new(source));
140 self
141 }
142
143 /// Sets the preferred underlay, applied to whatever endhost API discovery
144 /// returns (default: UDP).
145 #[must_use]
146 pub fn with_preferred_underlay(mut self, underlay: PreferredUnderlay) -> Self {
147 self.preferred_underlay = Some(underlay);
148 self
149 }
150
151 /// Customizes the [`ScionStackBuilder`] used to build connectivity, for
152 /// settings this configuration does not expose directly (underlay-specific
153 /// configuration, discovery tuning, token sources).
154 ///
155 /// The hook runs every time connectivity is (re)built: on first use and
156 /// after every [`reset`](crate::Client::reset). It runs after the settings
157 /// above are applied, so it can override them.
158 // XXX(shitz): This hook exists because `SnapUnderlayConfig`/`UdpUnderlayConfig`
159 // are single-use (not `Clone`) while every rebuild needs a fresh
160 // `ScionStackBuilder` from the same settings. Once they are made `Clone`
161 // in scion-stack, replace this with direct `with_snap_underlay_config` and
162 // `with_udp_underlay_config` setters.
163 #[must_use]
164 pub fn with_stack_customizer(
165 mut self,
166 customizer: impl Fn(ScionStackBuilder) -> ScionStackBuilder + Send + Sync + 'static,
167 ) -> Self {
168 self.stack_customizer = Some(Arc::new(customizer));
169 self
170 }
171
172 /// Sets the timeout for establishing a connection to an origin, covering
173 /// name resolution and all staggered connection attempts.
174 #[must_use]
175 pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
176 self.connect_timeout = timeout;
177 self
178 }
179
180 /// Sets the default timeout for a whole request: connectivity build,
181 /// establishment, response head, and body collection. Overridable per
182 /// request with
183 /// [`RequestBuilder::request_timeout`](crate::RequestBuilder::request_timeout).
184 ///
185 /// It is one deadline across all of those phases, and it keeps running while
186 /// the caller holds the [`Response`](crate::Response) — see there for what
187 /// that means for body collection.
188 #[must_use]
189 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
190 self.request_timeout = timeout;
191 self
192 }
193
194 /// Sets how long an origin's pooled connection may sit unused before an
195 /// opportunistic sweep reclaims it (and its socket).
196 #[must_use]
197 pub fn with_idle_connection_timeout(mut self, timeout: Duration) -> Self {
198 self.idle_connection_timeout = timeout;
199 self
200 }
201
202 /// Bounds the number of distinct origins with pooled connections. Each
203 /// pooled connection holds a socket, and therefore a file descriptor;
204 /// when the bound is hit, the least recently used origin is evicted (and
205 /// the eviction logged).
206 #[must_use]
207 pub fn with_max_origins(mut self, max_origins: usize) -> Self {
208 self.max_origins = max_origins;
209 self
210 }
211
212 /// Sets the stagger between connection attempts to an origin's candidate
213 /// addresses: the next candidate is only tried this long after the
214 /// previous one, unless it already failed (RFC 8305, Happy Eyeballs).
215 #[must_use]
216 pub fn with_connection_attempt_delay(mut self, delay: Duration) -> Self {
217 self.connection_attempt_delay = delay;
218 self
219 }
220
221 /// Sets the QUIC configuration used for every connection: trust anchors
222 /// (`ca_certs_file` / `ca_certs_dir` / `ca_certs_pem`), peer verification,
223 /// handshake and idle timeouts, and transport tuning. The application
224 /// protocol list must contain `h3` (the default).
225 #[must_use]
226 pub fn with_quic_config(mut self, quic: QuicConfig) -> Self {
227 self.quic = quic;
228 self
229 }
230
231 /// Replaces the default DNS resolver
232 /// ([`ScionTxtDnsResolver`](scion_stack::resolver::txt::ScionTxtDnsResolver))
233 /// with a caller-supplied one.
234 ///
235 /// The default resolver is constructed fresh on every connectivity
236 /// rebuild, because system DNS configuration is a property of the current
237 /// network. An injected resolver is reused across rebuilds as-is;
238 /// reacting to network changes is then its responsibility.
239 #[must_use]
240 pub fn with_resolver(mut self, resolver: Arc<dyn ScionDnsResolver>) -> Self {
241 self.resolver = Some(resolver);
242 self
243 }
244}
245
246impl fmt::Debug for Config {
247 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248 f.debug_struct("Config")
249 .field("endhost_api", &self.endhost_api)
250 .field(
251 "auth_token_source",
252 &self.auth_token_source.as_ref().map(|_| "<redacted>"),
253 )
254 .field("preferred_underlay", &self.preferred_underlay)
255 .field(
256 "stack_customizer",
257 &self.stack_customizer.as_ref().map(|_| "<fn>"),
258 )
259 .field("connect_timeout", &self.connect_timeout)
260 .field("request_timeout", &self.request_timeout)
261 .field("idle_connection_timeout", &self.idle_connection_timeout)
262 .field("max_origins", &self.max_origins)
263 .field("connection_attempt_delay", &self.connection_attempt_delay)
264 .field("quic", &self.quic)
265 .field("resolver", &self.resolver.as_ref().map(|_| "<custom>"))
266 .finish()
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn config() -> Config {
275 Config::new("https://endhost-api.example.org".parse().unwrap())
276 }
277
278 #[test]
279 fn new_applies_defaults() {
280 let config = config();
281 assert_eq!(config.connect_timeout, DEFAULT_CONNECT_TIMEOUT);
282 assert_eq!(config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
283 assert_eq!(
284 config.idle_connection_timeout,
285 DEFAULT_IDLE_CONNECTION_TIMEOUT
286 );
287 assert_eq!(config.max_origins, DEFAULT_MAX_ORIGINS);
288 assert_eq!(
289 config.connection_attempt_delay,
290 DEFAULT_CONNECTION_ATTEMPT_DELAY
291 );
292 }
293
294 #[test]
295 fn default_idle_sweep_precedes_quic_idle_timeout() {
296 let config = config();
297 assert!(config.idle_connection_timeout < config.quic.idle_timeout);
298 }
299
300 #[test]
301 fn debug_redacts_the_auth_token() {
302 let config = config().with_auth_token("super-secret-token");
303 let debug = format!("{config:?}");
304 assert!(!debug.contains("super-secret-token"));
305 assert!(debug.contains("<redacted>"));
306 }
307}