Skip to main content

web_faith/agent/
warm.rs

1//! The agent's warm-up verbs.
2
3// spec:WARM
4
5use std::{future::Future, sync::atomic::Ordering};
6
7use moka::sync::Cache as MokaCache;
8use reqwest::Version;
9
10use crate::{
11	agent::Agent,
12	error::{FaithError, FaithErrorKind},
13	warm_up::{extract_host, origin_key, reduce_to_origin},
14};
15
16impl Agent {
17	/// Warm the DNS cache for `host`, so a later request to it skips the lookup.
18	///
19	/// Takes a bare host; a scheme, port or path is ignored. The future completes when the answer
20	/// lands and never fails — the work is advisory — and does nothing under the system resolver,
21	/// which has no cache to warm. A host with nothing to resolve, or a closed agent, is refused
22	/// here rather than by the future.
23	// spec:WARM
24	pub fn prefetch_dns(&self, host: &str) -> Result<impl Future<Output = ()> + use<>, FaithError> {
25		if self.is_closed() {
26			return Err(FaithErrorKind::Closed.into());
27		}
28
29		let Some(host) = extract_host(host) else {
30			return Err(FaithErrorKind::AddressParse.into());
31		};
32
33		#[cfg(feature = "dns")]
34		let resolver = self.dns_resolver_inner();
35		Ok(async move {
36			// Nothing to warm without Faith's own resolver: the platform's cache is not ours to fill.
37			#[cfg(feature = "dns")]
38			if let Some(resolver) = resolver {
39				resolver.prefetch(&host).await;
40			}
41			#[cfg(not(feature = "dns"))]
42			let _ = host;
43		})
44	}
45
46	/// Open a pooled connection to `origin`, so the first request to it skips DNS, TCP and TLS
47	/// setup.
48	///
49	/// Takes an origin (`scheme://host[:port]`); a longer URL is reduced to one. Sends a synthetic
50	/// `HEAD` to the origin's root — which the origin will see in its logs — over the transport the
51	/// next request would use. The future completes when the attempt finishes and never fails.
52	/// Something unconnectable, or a closed agent, is refused here rather than by the future.
53	// spec:WARM
54	pub fn preconnect(&self, origin: &str) -> Result<impl Future<Output = ()> + use<>, FaithError> {
55		let Some(raw_client) = self.raw_client() else {
56			return Err(FaithErrorKind::Closed.into());
57		};
58
59		let Some(url) = reduce_to_origin(origin) else {
60			return Err(FaithErrorKind::AddressParse.into());
61		};
62		let key = origin_key(&url);
63
64		// Already warm within the idle window, or a warm-up for this origin already in flight:
65		// either way there is no new work to do, so finish without opening a duplicate.
66		let redundant = self.warmed.contains_key(&key)
67			|| !self.warming.entry(key.clone()).or_insert(()).is_fresh();
68
69		// The transport the next foreground request would take, decided exactly as the Alt-Svc
70		// layer decides it: nothing upgrades with the machinery off; with a prober, only a
71		// confirmed origin routes to QUIC (an advertisement is evidence worth probing, not worth
72		// routing on); without one, the inline upgrade acts on advertisements too. Diverging here
73		// would warm the wrong transport.
74		// spec:WARM#preconnect
75		#[cfg(feature = "http3")]
76		let h3_port = self
77			.alt_svc_cache()
78			.filter(|_| self.h3_upgrade_enabled)
79			.and_then(|cache| {
80				if self.h3_prober().is_some() {
81					cache.confirmed_port(&url)
82				} else {
83					cache.should_use_h3(&url)
84				}
85			});
86		#[cfg(not(feature = "http3"))]
87		let h3_port: Option<u16> = None;
88
89		#[cfg(feature = "connection-tracking")]
90		let conn_tracker = self.conn_tracker.clone();
91		let warmed = self.warmed.clone();
92		let warming = self.warming.clone();
93		// Read before the warm-up starts, to compare against once it finishes.
94		let warm_generation = self.warm_generation.clone();
95		let generation = warm_generation.load(Ordering::Relaxed);
96
97		Ok(async move {
98			if redundant {
99				return;
100			}
101
102			// Release the single-flight claim whatever happens, so a later warm-up is not blocked
103			// by this one having finished.
104			struct ReleaseClaim {
105				warming: MokaCache<String, ()>,
106				key: String,
107			}
108			impl Drop for ReleaseClaim {
109				fn drop(&mut self) {
110					self.warming.invalidate(&self.key);
111				}
112			}
113			let _release = ReleaseClaim {
114				warming,
115				key: key.clone(),
116			};
117
118			let request = match h3_port {
119				Some(port) => {
120					let mut h3_url = url.clone();
121					// A port differing from the origin's only comes back with the
122					// follow-advertised-port option on; rewriting the URL is how reqwest is told to
123					// connect there, mirroring the foreground path.
124					if Some(port) != h3_url.port_or_known_default() {
125						let _ = h3_url.set_port(Some(port));
126					}
127					raw_client.head(h3_url).version(Version::HTTP_3)
128				}
129				None => raw_client.head(url.clone()),
130			};
131
132			let outcome = request.send().await;
133
134			// A TCP warm-up leaves a pooled connection to track; a QUIC one does not (QUIC
135			// connections are not tracked, and a confirmed origin has nothing left to probe).
136			#[cfg(feature = "connection-tracking")]
137			if h3_port.is_none()
138				&& let Ok(response) = &outcome
139				&& let Some(info) = response
140					.extensions()
141					.get::<hyper_util::client::legacy::connect::HttpInfo>()
142			{
143				conn_tracker.track_warmup(info.local_addr(), info.remote_addr());
144			}
145
146			// A network change while this was in flight leaves the origin unmarked: the connection
147			// landed in the pool that change dropped, so it is not warm however well the request
148			// went.
149			// spec:NETCHG#reach-across-the-subsystems
150			if outcome.is_ok() && warm_generation.load(Ordering::Relaxed) == generation {
151				warmed.insert(key, ());
152			}
153		})
154	}
155}