Skip to main content

web_faith/
agent.rs

1//! The agent and its builder.
2
3pub use crate::builder::AgentOptionsBuilder;
4pub use crate::stats::AgentStats;
5
6#[cfg(feature = "cache")]
7pub use crate::options::{CacheOptions, CacheStore};
8pub use crate::options::{
9	DnsOptions, DnsOverride, FlowControlOptions, Header, Http2Options, PoolOptions, QuirksOptions,
10	RedirectPolicy, TimeoutOptions, TlsOptions,
11};
12#[cfg(feature = "http3")]
13pub use crate::options::{Http3Congestion, Http3Hint, Http3Options};
14
15// spec:AGENT spec:WARM spec:NETCHG spec:OBS
16
17use std::sync::{
18	Arc, RwLock,
19	atomic::{AtomicU64, Ordering},
20};
21
22use moka::sync::Cache as MokaCache;
23use reqwest::Client;
24use reqwest_middleware::ClientWithMiddleware;
25use url::Url;
26
27#[cfg(feature = "encoding")]
28use http::header::HeaderValue;
29
30#[cfg(feature = "connection-tracking")]
31use web_faith_conn_tracker::{ConnectionSnapshot, ConnectionTracker};
32
33#[cfg(feature = "cookies")]
34use web_faith_cookies::FaithJar;
35
36#[cfg(feature = "dns")]
37use web_faith_dns::{FaithResolver, ResolverReport};
38
39#[cfg(feature = "http3")]
40use web_faith_alt_svc::{AltSvcCache, H3Prober};
41
42use crate::{body::DrainPolicy, client::ClientRecipe, stats::InnerAgentStats, warm_up::origin_key};
43
44#[cfg(all(feature = "http3", feature = "dns"))]
45use crate::client::install_https_sink;
46
47mod build;
48mod warm;
49
50#[cfg(test)]
51mod tests;
52
53/// The agent settings a request consults, as opposed to those a client is built from.
54#[derive(Debug, Clone, Default)]
55pub(crate) struct AgentSettings {
56	/// Whether an HTTP/3 upgrade may follow a port the origin advertised, which a request needs so
57	/// a rewritten port is not reported as a redirect.
58	pub(crate) h3_follow_advertised_port: bool,
59	/// Whether a streaming request body may go out over HTTP/1.x.
60	pub(crate) quirk_h1_request_streaming: bool,
61	/// How much of an abandoned HTTP/1 body is read out to save its connection.
62	pub(crate) drain: DrainPolicy,
63	/// The agent's default `Accept-Encoding`. Decides which codings a response is decoded under
64	/// when a request adds none of its own.
65	#[cfg(feature = "encoding")]
66	pub(crate) default_accept_encoding: Option<HeaderValue>,
67	/// The agent's default `Content-Encoding`. A request layers its own coding on top of this.
68	#[cfg(feature = "encoding")]
69	pub(crate) default_content_encoding: Option<HeaderValue>,
70	/// Whether a `Priority` header sits among the agent's default headers, so that default wins
71	/// over the one a request's priority would derive.
72	pub(crate) has_default_content_type: bool,
73	pub(crate) has_default_priority: bool,
74}
75
76/// The resources an agent holds while open, and gives up when closed.
77///
78/// Behind a shared lock so closing acts on the agent rather than the handle it was called through.
79#[derive(Debug)]
80pub(crate) struct Live {
81	/// Holds the connection pool, DNS resolver and background tasks, so dropping it releases them.
82	pub(crate) client: ClientWithMiddleware,
83	/// The raw client behind [`Self::client`], sharing its pool. A warm-up sends here to skip the
84	/// HTTP cache and the Alt-Svc layer while still pooling the connection.
85	// spec:WARM
86	pub(crate) raw_client: Client,
87	/// The DNS resolver, shared with the client so a prefetch warms the cache requests read. `None`
88	/// under the system resolver, where there is no such cache.
89	// spec:WARM
90	#[cfg(feature = "dns")]
91	pub(crate) dns_resolver: Option<FaithResolver>,
92	#[cfg(feature = "http3")]
93	pub(crate) alt_svc_cache: Option<Arc<AltSvcCache>>,
94	/// Held so closing can abort in-flight probes; each owns a clone of the raw client, which would
95	/// otherwise keep the pool alive past close for up to the probe timeout.
96	#[cfg(feature = "http3")]
97	pub(crate) h3_prober: Option<Arc<H3Prober>>,
98}
99
100/// A Faith HTTP agent: where all fetches start.
101///
102/// An agent holds the resources and state shared across requests — connection pool, caches, DNS
103/// resolver, cookie jar, HTTP/3 upgrade memory — and is the browser instance of this library. A
104/// typical application makes one and starts every request from it.
105///
106/// [`Agent::new`] takes the defaults; [`Agent::builder`] configures one.
107// spec:AGENT
108#[derive(Debug, Clone)]
109pub struct Agent {
110	/// `None` once [`Agent::close`] has been called.
111	live: Arc<RwLock<Option<Live>>>,
112	/// Origins with a warm-up connection opened within the pool idle window, so a repeat
113	/// warm-up does no new work. Keyed by `scheme://host:port`; entries expire with the idle
114	/// timeout.
115	// spec:WARM
116	pub(crate) warmed: MokaCache<String, ()>,
117	/// Single-flight claims for warm-ups in flight, so concurrent calls for the same
118	/// origin do not open duplicate connections.
119	// spec:WARM
120	pub(crate) warming: MokaCache<String, ()>,
121	/// Bumped by [`Self::network_changed`], so a warm-up in flight across the signal does not record
122	/// its origin as warm — its connection went into the pool that was just dropped.
123	// spec:NETCHG#reach-across-the-subsystems
124	pub(crate) warm_generation: Arc<AtomicU64>,
125	/// The jar outlives a close and stays readable from a closed agent.
126	#[cfg(feature = "cookies")]
127	pub(crate) cookie_jar: Option<Arc<FaithJar>>,
128	pub(crate) stats: Arc<InnerAgentStats>,
129	#[cfg(feature = "connection-tracking")]
130	pub(crate) conn_tracker: Arc<ConnectionTracker>,
131	/// Whether an upgrade may follow a port the origin advertised. A request needs it to stop a
132	/// rewritten port from being reported as a redirect.
133	pub(crate) h3_follow_advertised_port: bool,
134	/// Whether the upgrade machinery is on at all. A warm-up needs it to route the way a foreground
135	/// request would: with it off, nothing upgrades, whatever the caches hold.
136	// spec:WARM#preconnect
137	#[cfg(feature = "http3")]
138	pub(crate) h3_upgrade_enabled: bool,
139	/// Whether a streaming request body may go out over HTTP/1.x, which the fetch standard otherwise
140	/// reserves to HTTP/2 and HTTP/3.
141	// spec:QUIRK#http-1-x-request-body-streaming
142	pub(crate) quirk_h1_request_streaming: bool,
143	/// How much of an abandoned HTTP/1 body is read out to save its connection.
144	// spec:POOL#draining-abandoned-http-1-bodies
145	pub(crate) drain: DrainPolicy,
146	/// The agent's default `Accept-Encoding`. Decides the codings a response is decoded under when
147	/// a request adds none of its own.
148	#[cfg(feature = "encoding")]
149	pub(crate) default_accept_encoding: Option<HeaderValue>,
150	/// The agent's default `Content-Encoding`. A request layers its own coding on top of this.
151	// spec:ENC
152	#[cfg(feature = "encoding")]
153	pub(crate) default_content_encoding: Option<HeaderValue>,
154	/// Whether a `Content-Type` sits among the agent's default headers. A type the agent declares
155	/// describes the bodies its requests carry, so it wins over the one a body's kind implies.
156	// spec:REQ#body
157	pub(crate) has_default_content_type: bool,
158	/// Whether a `Priority` header sits among the agent's default headers. That default wins over
159	/// the header a request's priority would derive.
160	pub(crate) has_default_priority: bool,
161	/// How to build this agent's clients, so [`Self::network_changed`] can build them again.
162	// spec:NETCHG
163	pub(crate) recipe: Arc<ClientRecipe>,
164}
165
166impl Agent {
167	/// The agent's cookie jar, if it keeps one.
168	///
169	/// The jar itself, so cookies go in and out through the type `web-faith-cookies` documents. It
170	/// stays readable after [`Self::close`].
171	// spec:COOK
172	#[cfg(feature = "cookies")]
173	pub fn cookies(&self) -> Option<&Arc<FaithJar>> {
174		self.cookie_jar.as_ref()
175	}
176
177	/// The client this agent sends through, or `None` once it is closed.
178	///
179	/// A request takes its handle when it is issued, which lets one already in flight
180	/// finish while a later one is refused.
181	// spec:AGENT
182	#[cfg(feature = "raw-client")]
183	pub fn client(&self) -> Option<ClientWithMiddleware> {
184		self.live().as_ref().map(|live| live.client.clone())
185	}
186
187	// spec:AGENT
188	#[cfg(not(feature = "raw-client"))]
189	pub(crate) fn client(&self) -> Option<ClientWithMiddleware> {
190		self.live().as_ref().map(|live| live.client.clone())
191	}
192
193	/// The same client without Faith's middleware.
194	///
195	/// A request on it skips the HTTP cache and the Alt-Svc layer, while sharing the connection
196	/// pool.
197	#[cfg(feature = "raw-client")]
198	pub fn raw_client(&self) -> Option<Client> {
199		self.live().as_ref().map(|live| live.raw_client.clone())
200	}
201
202	#[cfg(not(feature = "raw-client"))]
203	pub(crate) fn raw_client(&self) -> Option<Client> {
204		self.live().as_ref().map(|live| live.raw_client.clone())
205	}
206
207	/// The agent's DNS resolver, or `None` once it is closed.
208	#[cfg(all(feature = "dns", feature = "raw-client"))]
209	pub fn dns_resolver(&self) -> Option<FaithResolver> {
210		self.dns_resolver_inner()
211	}
212
213	#[cfg(feature = "dns")]
214	pub(crate) fn dns_resolver_inner(&self) -> Option<FaithResolver> {
215		self.live()
216			.as_ref()
217			.and_then(|live| live.dns_resolver.clone())
218	}
219
220	#[cfg(feature = "http3")]
221	fn alt_svc_cache(&self) -> Option<Arc<AltSvcCache>> {
222		self.live()
223			.as_ref()
224			.and_then(|live| live.alt_svc_cache.clone())
225	}
226
227	#[cfg(feature = "http3")]
228	fn h3_prober(&self) -> Option<Arc<H3Prober>> {
229		self.live().as_ref().and_then(|live| live.h3_prober.clone())
230	}
231
232	fn live(&self) -> std::sync::RwLockReadGuard<'_, Option<Live>> {
233		self.live
234			.read()
235			.unwrap_or_else(|poisoned| poisoned.into_inner())
236	}
237
238	fn live_mut(&self) -> std::sync::RwLockWriteGuard<'_, Option<Live>> {
239		self.live
240			.write()
241			.unwrap_or_else(|poisoned| poisoned.into_inner())
242	}
243
244	/// Close the agent, releasing its connection pool, DNS resolver, and background tasks without
245	/// waiting for the last clone to drop. Worth doing if you make many short-lived agents.
246	///
247	/// Requests already in flight run to completion. A request issued on a closed agent fails
248	/// with [`FaithErrorKind::Closed`](crate::error::FaithErrorKind::Closed). Calling it more
249	/// than once is a no-op, and the cookie jar, if any, stays readable through `cookies()`.
250	pub fn close(&self) {
251		// Dropping the client releases the reqwest connection pool and the
252		// Hickory resolver task; the alt-svc cache goes with it. The raw client
253		// shares that pool and the resolver, so it goes too, and both are what a
254		// later warm-up checks to refuse with the closed-agent error.
255		// Taken out of the shared cell, so every handle on this agent sees it closed.
256		let Some(live) = self.live_mut().take() else {
257			return;
258		};
259
260		// Probes hold a raw client clone; abort them so the pool doesn't outlive close by up to
261		// the probe timeout.
262		#[cfg(feature = "http3")]
263		if let Some(prober) = &live.h3_prober {
264			prober.abort_all();
265		}
266
267		drop(live);
268	}
269
270	/// Tell the agent the network under it has changed, so it stops acting on what it learned
271	/// about a network that is gone.
272	///
273	/// There is no portable signal for an interface or connectivity change, so call this yourself
274	/// on whatever trigger fits — an OS notification, a VPN transition, a captive-portal sign-in.
275	///
276	/// Drops pooled connections, flushes the DNS cache, demotes confirmed HTTP/3 origins back to
277	/// advertised so a probe re-verifies them, and clears the HTTP/3 failure, slow and path-time
278	/// state. Configuration, `http3.hints`, `Alt-Svc` advertisements, the cookie jar, the HTTP
279	/// cache and the counters are kept — none of those is a claim about a network path.
280	///
281	/// Requests in flight run to completion on the connections they hold. Harmless to call
282	/// repeatedly, or on a closed agent.
283	// spec:NETCHG
284	pub fn network_changed(&self) {
285		{
286			// Held across the rebuild so a close cannot land halfway through it.
287			let mut guard = self.live_mut();
288			// A closed agent has already released all of this.
289			let Some(live) = guard.as_mut() else {
290				return;
291			};
292
293			// reqwest cannot drop pooled connections short of dropping the client, so the client is
294			// rebuilt from the recipe the agent kept for this. Requests in flight hold the handle
295			// they took when they were issued, so they run to completion and the old pool goes when
296			// the last of them finishes.
297			//
298			// A rebuild that fails leaves the agent on its existing client: the options were already
299			// validated at construction, so a failure here is not the caller's to answer for, and an
300			// agent that still works on the old network beats one that works nowhere.
301			let built = self.recipe.build(
302				#[cfg(feature = "cookies")]
303				self.cookie_jar.as_ref(),
304				#[cfg(feature = "dns")]
305				live.dns_resolver.as_ref(),
306				#[cfg(feature = "http3")]
307				live.alt_svc_cache.as_ref(),
308			);
309			if let Ok(built) = built {
310				#[cfg(feature = "http3")]
311				{
312					// Abort probes running on the old client: each holds a clone of it, and their
313					// answers would describe the path that has just gone away.
314					if let Some(prober) = &live.h3_prober {
315						prober.abort_all();
316					}
317					live.h3_prober = built.prober;
318					// The sink holds the prober, which has just been replaced along with the client
319					// it sends on; leaving the old one installed would aim DNS-triggered probes at a
320					// client that has been dropped.
321					#[cfg(feature = "dns")]
322					install_https_sink(
323						live.dns_resolver.as_ref(),
324						live.alt_svc_cache.as_ref(),
325						live.h3_prober.as_ref(),
326						self.h3_upgrade_enabled,
327					);
328				}
329				live.client = built.client;
330				live.raw_client = built.raw_client;
331			}
332
333			// Names resolve afresh against the new network, through that network's own servers: the
334			// resolver drops what it read off the old one and reads again when next used. Under the
335			// system resolver there is no resolver here and so nothing to reset.
336			// spec:DNS
337			#[cfg(feature = "dns")]
338			if let Some(resolver) = &live.dns_resolver {
339				resolver.reset();
340			}
341
342			#[cfg(feature = "http3")]
343			if let Some(alt_svc_cache) = &live.alt_svc_cache {
344				alt_svc_cache.network_changed();
345			}
346		}
347
348		// The warm-up records describe pooled connections that have just been dropped, so a
349		// `preconnect` after the signal opens a connection rather than finding the origin warm
350		// (spec:NETCHG, spec:WARM). The single-flight claims are left alone: a warm-up still in
351		// flight is not duplicated by releasing its claim, and the generation bump is what stops
352		// it recording an origin as warm on the strength of a connection in the dropped pool.
353		self.warmed.invalidate_all();
354		self.warm_generation.fetch_add(1, Ordering::Relaxed);
355	}
356
357	/// The agent's counters, as they stand.
358	pub fn stats(&self) -> AgentStats {
359		self.stats.snapshot()
360	}
361
362	/// The connections this agent currently holds open.
363	///
364	/// TCP only; QUIC connections are not visible here. Statistics refresh once a second, so sample
365	/// over time for rates such as retransmissions. Which fields are filled depends on the platform:
366	/// the lost-packet count and delivery rate are Linux-only, an unsupported platform reports an
367	/// empty list, and no field is guaranteed to stay available.
368	#[cfg(feature = "connection-tracking")]
369	pub fn connections(&self) -> Vec<ConnectionSnapshot> {
370		self.conn_tracker.snapshot()
371	}
372
373	/// The DNS servers this agent resolves through, in query order.
374	///
375	/// Each entry gives the nameserver's address, the transport in use, and how that was arrived
376	/// at. Empty until the resolver has been used, and empty under the system resolver.
377	// spec:OBS#resolvers
378	#[cfg(feature = "dns")]
379	pub fn resolvers(&self) -> Vec<ResolverReport> {
380		self.dns_resolver_inner()
381			.as_ref()
382			.map(FaithResolver::resolvers)
383			.unwrap_or_default()
384	}
385
386	/// Note that a request reached this origin, so a `preconnect` for it has no new work to do.
387	///
388	/// Called for foreground requests as well as warm-ups: the criterion is that the origin holds
389	/// an idle pooled connection, not how it came to.
390	// spec:WARM
391	pub(crate) fn mark_warm(&self, url: &Url) {
392		self.warmed.insert(origin_key(url), ());
393	}
394
395	/// Whether [`Self::close`] has been called.
396	pub fn is_closed(&self) -> bool {
397		self.live().is_none()
398	}
399}