Skip to main content

web_faith/agent/
build.rs

1//! Agent construction.
2
3// spec:AGENT
4
5use std::{
6	net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4, SocketAddrV6},
7	str::FromStr,
8	sync::{Arc, RwLock},
9	time::Duration,
10};
11
12use http::header::{HeaderMap, HeaderName, HeaderValue};
13
14#[cfg(feature = "cache")]
15use crate::{
16	client::{HttpCacheRecipe, HttpCacheStore},
17	options::CacheStore,
18};
19
20#[cfg(feature = "cache")]
21use http_cache_reqwest::{
22	CACacheManager, CacheOptions, HttpCacheOptions, MokaCacheBuilder, MokaManager,
23};
24use moka::sync::Cache as MokaCache;
25use reqwest::{Identity, tls::Certificate};
26
27#[cfg(feature = "connection-tracking")]
28use web_faith_conn_tracker::ConnectionTracker;
29
30#[cfg(feature = "cookies")]
31use web_faith_cookies::FaithJar;
32
33#[cfg(feature = "dns")]
34use web_faith_dns::{DEFAULT_MAX_STALE, FaithResolver, ResolverConfig, ServerSpec, parse_domains};
35
36#[cfg(feature = "http3")]
37use web_faith_alt_svc::{AltSvcCache, AltSvcCacheConfig};
38
39use crate::{
40	USER_AGENT,
41	agent::{Agent, AgentSettings, Live},
42	body::DrainPolicy,
43	client::{ClientRecipe, NodeEnvRecipe},
44	error::{FaithError, FaithErrorKind},
45	options::{AgentOptions, DnsOverride, Header, ipv6_wildcard_bindable, resolve_windows},
46	request::PRIORITY,
47};
48
49#[cfg(feature = "http3")]
50use crate::{client::H3UpgradeRecipe, options::Http3Congestion};
51
52#[cfg(all(feature = "http3", feature = "dns"))]
53use crate::client::install_https_sink;
54
55/// Building an agent from `AgentOptions` directly. Permanently unstable; [`Agent::builder`] is the
56/// supported route.
57#[cfg(feature = "unstable-internals")]
58impl Agent {
59	/// Build an agent straight from options.
60	pub fn from_options(options: AgentOptions) -> Result<Self, FaithError> {
61		Self::from_options_impl(options)
62	}
63}
64
65impl Agent {
66	// spec:AGENT spec:NETCHG
67	pub(crate) fn from_options_impl(options: AgentOptions) -> Result<Self, FaithError> {
68		// Destructured rather than read field by field so that a new option cannot be added
69		// without the compiler pointing here, where every option is turned into the recipe the
70		// agent's clients are built from (spec:NETCHG).
71		let AgentOptions {
72			#[cfg(feature = "cache")]
73			cache,
74			#[cfg(feature = "cookies")]
75			cookies,
76			dns,
77			flow_control,
78			headers,
79			http2,
80			#[cfg(feature = "http3")]
81			http3,
82			local_address,
83			pool,
84			quirks,
85			redirect,
86			timeout,
87			tls,
88			user_agent,
89		} = options;
90
91		let quirk_h1_request_streaming = quirks
92			.and_then(|quirks| quirks.h1_request_streaming)
93			.unwrap_or(false);
94
95		// Local bind address. An explicit value is honoured as-is. Otherwise, on hosts
96		// without usable IPv6, bind 0.0.0.0: reqwest binds the QUIC (HTTP/3) socket to the
97		// IPv6 wildcard `[::]` by default, which fails to construct on IPv4-only hosts and
98		// makes HTTP/3 silently fall back to TCP. Binding 0.0.0.0 there costs nothing (such
99		// a host can't use IPv6 for TCP either) and keeps HTTP/3 working.
100		let local_address = match local_address {
101			Some(address) => Some(address),
102			None if !ipv6_wildcard_bindable() => Some(IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
103			None => None,
104		};
105
106		// `cookies: true` takes the default limits; an options object tunes them. (spec:COOK)
107		// The jar is installed on the client by the recipe, so it survives a rebuild
108		// (spec:NETCHG#what-the-signal-keeps).
109		#[cfg(feature = "cookies")]
110		let cookie_jar = cookies.map(|limits| Arc::new(FaithJar::new(limits)));
111
112		let dns = dns.unwrap_or_default();
113		// Without Faith's own resolver there is nothing to choose: every name goes to the platform,
114		// and no part of the build asks the question.
115		#[cfg(feature = "dns")]
116		let dns_system = dns.system.unwrap_or(false);
117		// Naming servers and asking for the system resolver at once is a contradiction rather than
118		// a preference, since the system resolver is not Faith's to point at listed servers
119		// (spec:DNS#system-resolver).
120		#[cfg(feature = "dns")]
121		if dns_system
122			&& dns
123				.servers
124				.as_ref()
125				.is_some_and(|servers| !servers.is_empty())
126		{
127			return Err(FaithError::new(
128				FaithErrorKind::Config,
129				"dns.servers cannot be combined with dns.system".to_string(),
130			));
131		}
132		// Parsed whichever resolver is in use: overrides take effect under the system resolver
133		// too (spec:DNS#overrides), and an unparseable address is a construction error either
134		// way (spec:AGENT#construction).
135		let dns_overrides = dns
136			.overrides
137			.unwrap_or_default()
138			.into_iter()
139			.map(|DnsOverride { domain, addresses }| {
140				let addresses = addresses
141					.into_iter()
142					.map(|addr| match SocketAddr::from_str(&addr) {
143						Ok(addr) => Ok(addr),
144						Err(err) => match IpAddr::from_str(&addr) {
145							Ok(IpAddr::V4(ip)) => Ok(SocketAddr::V4(SocketAddrV4::new(ip, 0))),
146							Ok(IpAddr::V6(ip)) => {
147								Ok(SocketAddr::V6(SocketAddrV6::new(ip, 0, 0, 0)))
148							}
149							Err(_) => Err(FaithError::new(
150								FaithErrorKind::AddressParse,
151								format!("{addr:?}: {err}"),
152							)),
153						},
154					})
155					.collect::<Result<Vec<_>, FaithError>>()?;
156				Ok((domain, addresses))
157			})
158			.collect::<Result<Vec<_>, FaithError>>()?;
159
160		// Faith owns the hickory resolver rather than leaving it to reqwest's built-in one, so
161		// `prefetch_dns` can warm the very cache reqwest's requests read (spec:WARM),
162		// `network_changed` can flush it (spec:NETCHG), and `dns.servers` can pick the transport and
163		// order each resolver is reached by (spec:DNS#transports). The system resolver
164		// (getaddrinfo) has no in-process cache Faith can warm, so no resolver is installed there
165		// and `prefetch_dns` resolves as a no-op (spec:WARM).
166		#[cfg(feature = "dns")]
167		let dns_resolver = if dns_system {
168			None
169		} else {
170			// These settings configure Faith's own resolver only, so they are read on the path that
171			// builds one rather than validated under the system resolver that ignores them.
172			let mut servers = Vec::new();
173			for url in dns.servers.unwrap_or_default() {
174				servers.push(url.parse::<ServerSpec>().map_err(|err| {
175					FaithError::new(FaithErrorKind::AddressParse, format!("{url:?}: {err}"))
176				})?);
177			}
178			Some(FaithResolver::new(ResolverConfig {
179				servers,
180				timeout: dns.timeout.map(|ms| Duration::from_millis(ms.into())),
181				ndots: dns.ndots.map(|n| n as usize),
182				search_domains: parse_domains(dns.search_domains)
183					.map_err(|message| FaithError::new(FaithErrorKind::Config, message))?,
184				hosts_file: dns.hosts_file,
185				exempt_domains: parse_domains(dns.exempt_domains)
186					.map_err(|message| FaithError::new(FaithErrorKind::Config, message))?
187					.unwrap_or_default(),
188				// The two options stay separate on the surfaces, and meet here: `serveStale` says
189				// whether at all, `maxStale` how long for.
190				serve_stale: dns.serve_stale.unwrap_or(true).then(|| {
191					dns.max_stale
192						.map_or(DEFAULT_MAX_STALE, |ms| Duration::from_millis(ms.into()))
193				}),
194			}))
195		};
196
197		#[cfg(feature = "encoding")]
198		let mut default_accept_encoding = None;
199		#[cfg(feature = "encoding")]
200		let mut default_content_encoding = None;
201		let mut has_default_content_type = false;
202		let mut has_default_priority = false;
203		let mut default_headers = None;
204		if let Some(headers) = headers
205			&& !headers.is_empty()
206		{
207			let map = HeaderMap::from_iter(headers.into_iter().filter_map(
208				|Header {
209				     name,
210				     value,
211				     sensitive,
212				 }| {
213					let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else {
214						return None;
215					};
216
217					let Ok(mut value) = HeaderValue::from_bytes(value.as_bytes()) else {
218						return None;
219					};
220
221					if sensitive.unwrap_or(false) {
222						value.set_sensitive(true);
223					}
224
225					Some((name, value))
226				},
227			));
228			#[cfg(feature = "encoding")]
229			{
230				default_accept_encoding = map.get(reqwest::header::ACCEPT_ENCODING).cloned();
231				default_content_encoding = map.get(reqwest::header::CONTENT_ENCODING).cloned();
232			}
233			has_default_content_type = map.contains_key(reqwest::header::CONTENT_TYPE);
234			has_default_priority = map.contains_key(PRIORITY);
235			default_headers = Some(map);
236		}
237
238		// HTTP/2 flow control (spec:FLOW). Adaptive windowing takes over both windows itself, so
239		// the explicit sizes are not resolved at all when it's on: reqwest would let the later
240		// `http2_adaptive_window` call win regardless, but leaving them out makes the precedence
241		// visible here rather than depending on hyper's internal ordering.
242		let http2 = http2.unwrap_or_default();
243		let http2_adaptive_window = http2.adaptive_window.unwrap_or(false);
244		let http2_windows = (!http2_adaptive_window).then(|| {
245			resolve_windows(
246				flow_control.as_ref(),
247				http2.stream_window,
248				http2.connection_window,
249			)
250		});
251
252		#[cfg(feature = "http3")]
253		let http3_max_idle_timeout = Duration::from_secs(
254			http3
255				.as_ref()
256				.and_then(|h| h.max_idle_timeout)
257				.unwrap_or(30)
258				.clamp(1, 120)
259				.into(),
260		);
261
262		// QUIC flow control (spec:FLOW). quinn's own defaults are a ~1.25MB stream window
263		// inside an unbounded connection window: the stream window is the binding constraint
264		// on a high-latency link, and the unbounded connection window means a connection with
265		// many concurrent requests has no ceiling on what it buffers. Both are set here.
266		#[cfg(feature = "http3")]
267		let http3_windows = resolve_windows(
268			flow_control.as_ref(),
269			http3.as_ref().and_then(|h| h.stream_window),
270			http3.as_ref().and_then(|h| h.connection_window),
271		);
272
273		#[cfg(feature = "http3")]
274		let http3_congestion_bbr = matches!(
275			http3.as_ref().and_then(|h| h.congestion),
276			Some(Http3Congestion::Bbr1)
277		);
278
279		#[cfg(feature = "http3")]
280		let http3_send_window = http3.as_ref().and_then(|h| h.send_window);
281
282		let pool_idle_timeout = pool
283			.as_ref()
284			.and_then(|pool| pool.idle_timeout)
285			.map(|seconds| Duration::from_secs(seconds.into()));
286		// A pool group with no cap set means no limit, which is reqwest's own default (spec:POOL).
287		let pool_max_idle_per_host = pool.as_ref().map(|pool| {
288			pool.max_idle_per_host
289				.and_then(|n| n.try_into().ok())
290				.unwrap_or(usize::MAX)
291		});
292
293		// Unset limits take the defaults (spec:POOL#draining-abandoned-http-1-bodies).
294		let drain = {
295			let default = DrainPolicy::default();
296			DrainPolicy {
297				limit: pool
298					.as_ref()
299					.and_then(|pool| pool.drain_limit)
300					.map_or(default.limit, u64::from),
301				timeout: pool
302					.as_ref()
303					.and_then(|pool| pool.drain_timeout)
304					.map_or(default.timeout, |millis| {
305						Duration::from_millis(millis.into())
306					}),
307			}
308		};
309
310		let connect_timeout = timeout
311			.and_then(|t| t.connect)
312			.map(|millis| Duration::from_millis(millis.into()));
313		let read_timeout = timeout
314			.and_then(|t| t.read)
315			.map(|millis| Duration::from_millis(millis.into()));
316		let total_timeout = timeout
317			.and_then(|t| t.total)
318			.map(|millis| Duration::from_millis(millis.into()));
319
320		#[cfg(feature = "http3")]
321		let tls_early_data = tls.as_ref().and_then(|tls| tls.early_data);
322		let tls_required = tls.as_ref().and_then(|tls| tls.required);
323		// PEM inputs are parsed here rather than kept as bytes: the parsed forms are what a
324		// rebuilt client needs, and a syntax error belongs to construction (spec:AGENT#construction).
325		let (tls_identity, tls_extra_roots) = match tls {
326			None => (None, Vec::new()),
327			Some(tls) => {
328				let identity = match &tls.identity {
329					None => None,
330					Some(identity) => Some(Identity::from_pem(identity).map_err(|err| {
331						FaithError::new(FaithErrorKind::PemParse, err.to_string())
332					})?),
333				};
334
335				let mut extra_roots = Vec::new();
336				for pem in tls.extra_roots.iter().flatten() {
337					extra_roots.extend(Certificate::from_pem_bundle(pem).map_err(|err| {
338						FaithError::new(FaithErrorKind::PemParse, err.to_string())
339					})?);
340				}
341
342				(identity, extra_roots)
343			}
344		};
345
346		#[cfg(feature = "cache")]
347		let http_cache = if let Some(cache) = cache
348			&& let Some(store) = cache.store
349		{
350			let mode = cache.mode.unwrap_or_default().into();
351			let options = HttpCacheOptions {
352				cache_options: Some(CacheOptions {
353					shared: cache.shared.unwrap_or(true),
354					ignore_cargo_cult: true,
355					..Default::default()
356				}),
357				..Default::default()
358			};
359			let store = match store {
360				CacheStore::Disk => HttpCacheStore::Disk(CACacheManager {
361					path: cache
362						.path
363						.ok_or_else(|| {
364							FaithError::new(FaithErrorKind::Config, "missing cache.path")
365						})?
366						.into(),
367					remove_opts: Default::default(),
368				}),
369				CacheStore::Memory => HttpCacheStore::Memory(MokaManager::new(
370					MokaCacheBuilder::new(cache.capacity.map_or(10_000, |n| n.into())).build(),
371				)),
372			};
373
374			Some(HttpCacheRecipe {
375				mode,
376				options,
377				store,
378			})
379		} else {
380			None
381		};
382
383		// Read outside the `alt_svc_cache` block below because `fetch` needs it too,
384		// to keep a rewritten port from looking like a redirect.
385		#[cfg(feature = "http3")]
386		let h3_follow_advertised_port = http3
387			.as_ref()
388			.and_then(|o| o.upgrade_follow_advertised_port)
389			.unwrap_or(false);
390		#[cfg(not(feature = "http3"))]
391		let h3_follow_advertised_port = false;
392
393		// The origin knowledge is built once and outlives every client the agent builds: it is
394		// the agent's own, and a network change edits it rather than replacing it (spec:NETCHG).
395		#[cfg(feature = "http3")]
396		let (alt_svc_cache, h3_upgrade) = {
397			let http3_opts = http3.as_ref();
398			let enabled = http3_opts.and_then(|o| o.upgrade_enabled).unwrap_or(true);
399
400			let advertised_ttl = Duration::from_secs(
401				http3_opts
402					.and_then(|o| o.upgrade_advertised_ttl)
403					.unwrap_or(86400)
404					.into(),
405			);
406			let confirmed_ttl = Duration::from_secs(
407				http3_opts
408					.and_then(|o| o.upgrade_confirmed_ttl)
409					.unwrap_or(86400)
410					.into(),
411			);
412			let failed_ttl = Duration::from_secs(
413				http3_opts
414					.and_then(|o| o.upgrade_failed_ttl)
415					.unwrap_or(300)
416					.into(),
417			);
418			let failed_max_ttl = Duration::from_secs(
419				http3_opts
420					.and_then(|o| o.upgrade_failed_max_ttl)
421					.unwrap_or(3600)
422					.into(),
423			);
424			let capacity = http3_opts
425				.and_then(|o| o.upgrade_cache_capacity)
426				.unwrap_or(10_000)
427				.into();
428			let cancel_strikes = http3_opts
429				.and_then(|o| o.upgrade_cancel_strikes)
430				.unwrap_or(3);
431			let attempt_timeout = match http3_opts
432				.and_then(|o| o.upgrade_attempt_timeout)
433				.unwrap_or(60_000)
434			{
435				0 => None,
436				millis => Some(Duration::from_millis(millis.into())),
437			};
438			let probe = http3_opts.and_then(|o| o.upgrade_probe).unwrap_or(true);
439			let probe_timeout = match http3_opts
440				.and_then(|o| o.upgrade_probe_timeout)
441				.unwrap_or(5_000)
442			{
443				0 => None,
444				millis => Some(Duration::from_millis(millis.into())),
445			};
446			let slow_factor = http3_opts
447				.and_then(|o| o.upgrade_slow_factor)
448				.unwrap_or(2.5);
449			let slow_ttl = Duration::from_secs(
450				http3_opts
451					.and_then(|o| o.upgrade_slow_ttl)
452					.unwrap_or(600)
453					.into(),
454			);
455
456			let cache = Arc::new(AltSvcCache::new(AltSvcCacheConfig {
457				advertised_ttl,
458				confirmed_ttl,
459				failed_ttl,
460				failed_max_ttl,
461				capacity,
462				cancel_strikes,
463				strike_window: Duration::from_secs(60),
464				follow_advertised_port: h3_follow_advertised_port,
465				// The single-flight claim must outlive the probe it covers, so
466				// an aborted probe frees its origin without a report; without a
467				// probe deadline, the QUIC idle timeout (max 120s) is the bound.
468				probe_ttl: probe_timeout
469					.map_or(Duration::from_secs(125), |t| t + Duration::from_secs(5)),
470				slow_factor,
471				slow_ttl,
472			}));
473
474			if let Some(hints) = http3_opts.and_then(|o| o.hints.as_ref()) {
475				for hint in hints {
476					cache.add_hint(&hint.host, hint.port);
477				}
478			}
479
480			(
481				Some(cache),
482				H3UpgradeRecipe {
483					enabled,
484					attempt_timeout,
485					probe,
486					probe_timeout,
487				},
488			)
489		};
490
491		let recipe = ClientRecipe {
492			user_agent: user_agent.unwrap_or_else(|| USER_AGENT.to_owned()),
493			local_address,
494			default_headers,
495			#[cfg(feature = "dns")]
496			dns_system,
497			dns_overrides,
498			http2_adaptive_window,
499			http2_windows,
500			#[cfg(feature = "http3")]
501			http3_max_idle_timeout,
502			#[cfg(feature = "http3")]
503			http3_windows,
504			#[cfg(feature = "http3")]
505			http3_congestion_bbr,
506			#[cfg(feature = "http3")]
507			http3_send_window,
508			pool_idle_timeout,
509			pool_max_idle_per_host,
510			redirect,
511			connect_timeout,
512			read_timeout,
513			total_timeout,
514			#[cfg(feature = "http3")]
515			tls_early_data,
516			tls_identity,
517			tls_required,
518			tls_extra_roots,
519			node_env: NodeEnvRecipe::read(),
520			#[cfg(feature = "cache")]
521			http_cache,
522			#[cfg(feature = "http3")]
523			h3_upgrade,
524		};
525
526		let settings = AgentSettings {
527			h3_follow_advertised_port,
528			quirk_h1_request_streaming,
529			drain,
530			#[cfg(feature = "encoding")]
531			default_accept_encoding,
532			#[cfg(feature = "encoding")]
533			default_content_encoding,
534			has_default_content_type,
535			has_default_priority,
536		};
537
538		Self::build(
539			recipe,
540			settings,
541			#[cfg(feature = "cookies")]
542			cookie_jar,
543			#[cfg(feature = "dns")]
544			dns_resolver,
545			#[cfg(feature = "http3")]
546			alt_svc_cache,
547		)
548	}
549
550	/// An agent with default options.
551	pub fn new() -> Result<Self, FaithError> {
552		Self::from_options_impl(AgentOptions::default())
553	}
554
555	/// Build an agent a setting at a time.
556	///
557	/// Each option group is reached through a closure, so a group left alone is absent from the
558	/// call rather than spelled out as absent. Durations are `Duration` whatever unit the setting
559	/// is carried in, and anything unset takes its default.
560	///
561	/// ```no_run
562	/// use std::time::Duration;
563	/// use web_faith::Agent;
564	///
565	/// let agent = Agent::builder()
566	///     .user_agent("YourApp/1.2.3")
567	///     .timeout(|timeout| timeout.connect(Duration::from_secs(2)).build())
568	///     .pool(|pool| pool.max_idle_per_host(8).build())
569	///     .build()?;
570	/// # Ok::<(), web_faith::FaithError>(())
571	/// ```
572	pub fn builder() -> crate::agent::AgentOptionsBuilder {
573		crate::options::AgentOptions::builder()
574	}
575
576	/// Build an agent from a validated recipe.
577	///
578	/// A client is built from the recipe, and each request consults the settings;
579	/// validating whatever a caller expressed them as belongs to the surface that took it.
580	pub(crate) fn build(
581		recipe: ClientRecipe,
582		settings: AgentSettings,
583		#[cfg(feature = "cookies")] cookie_jar: Option<Arc<FaithJar>>,
584		#[cfg(feature = "dns")] dns_resolver: Option<FaithResolver>,
585		#[cfg(feature = "http3")] alt_svc_cache: Option<Arc<AltSvcCache>>,
586	) -> Result<Self, FaithError> {
587		let conn_timeout = recipe.conn_timeout();
588		let built = recipe.build(
589			#[cfg(feature = "cookies")]
590			cookie_jar.as_ref(),
591			#[cfg(feature = "dns")]
592			dns_resolver.as_ref(),
593			#[cfg(feature = "http3")]
594			alt_svc_cache.as_ref(),
595		)?;
596
597		// Only now do all three exist: the resolver is built before the cache, and the prober
598		// holds a client that holds the resolver, so this is the earliest the loop can be closed
599		// (spec:DNS#https-records).
600		#[cfg(all(feature = "http3", feature = "dns"))]
601		install_https_sink(
602			dns_resolver.as_ref(),
603			alt_svc_cache.as_ref(),
604			built.prober.as_ref(),
605			recipe.h3_upgrade.enabled,
606		);
607
608		Ok(Self {
609			live: Arc::new(RwLock::new(Some(Live {
610				client: built.client,
611				raw_client: built.raw_client,
612				#[cfg(feature = "dns")]
613				dns_resolver,
614				#[cfg(feature = "http3")]
615				alt_svc_cache,
616				#[cfg(feature = "http3")]
617				h3_prober: built.prober,
618			}))),
619			// A warm-up connection is warm only as long as the pool keeps it idle, so the record
620			// that an origin is warm expires with that same window.
621			warmed: MokaCache::builder().time_to_live(conn_timeout).build(),
622			// A safety TTL well past any reasonable warm-up, so a claim that never gets released
623			// (a warm-up whose task is dropped) frees the origin rather than wedging it.
624			warming: MokaCache::builder()
625				.time_to_live(Duration::from_secs(300))
626				.build(),
627			warm_generation: Default::default(),
628			#[cfg(feature = "cookies")]
629			cookie_jar,
630			stats: Default::default(),
631			#[cfg(feature = "connection-tracking")]
632			conn_tracker: ConnectionTracker::new(conn_timeout),
633			h3_follow_advertised_port: settings.h3_follow_advertised_port,
634			#[cfg(feature = "http3")]
635			h3_upgrade_enabled: recipe.h3_upgrade.enabled,
636			quirk_h1_request_streaming: settings.quirk_h1_request_streaming,
637			drain: settings.drain,
638			#[cfg(feature = "encoding")]
639			default_accept_encoding: settings.default_accept_encoding,
640			#[cfg(feature = "encoding")]
641			default_content_encoding: settings.default_content_encoding,
642			has_default_content_type: settings.has_default_content_type,
643			has_default_priority: settings.has_default_priority,
644			recipe: Arc::new(recipe),
645		})
646	}
647}