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