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}