trillium_client/client.rs
1use crate::{
2 ClientHandler, Conn, IntoUrl, Pool, USER_AGENT, client_handler::ArcedClientHandler,
3 conn::H2Pooled, h3::H3ClientState,
4};
5use std::{any::Any, fmt::Debug, sync::Arc, time::Duration};
6use trillium_http::{
7 HeaderName, HeaderValues, Headers, HttpContext, KnownHeaderName, Method, ProtocolSession,
8 ReceivedBodyState, TypeSet,
9};
10use trillium_server_common::{
11 ArcedConnector, ArcedQuicClientConfig, Connector, QuicClientConfig, Transport,
12 url::{Origin, Url},
13};
14
15/// Default maximum idle time for a pooled HTTP/1.1 connection. A warm keepalive connection is
16/// worth holding onto — reusing it saves a full TCP (and, for https, TLS) handshake — so the
17/// timeout exists only to bound *unbounded* retention, not to reclaim connections eagerly. A
18/// connection the origin closed first is discarded cheaply either way: by the reuse-time
19/// liveness probe when it's next reached for, or by the background reaper. Matches the h2
20/// default for the same reason.
21const DEFAULT_H1_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
22
23/// Default maximum idle time for a pooled HTTP/2 connection. Longer than h1 because the
24/// initial h2 handshake (TCP + TLS + ALPN + SETTINGS exchange) is more expensive to
25/// re-establish.
26const DEFAULT_H2_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
27
28/// Default maximum retention for a pooled HTTP/3 connection. Matches the h2 default; QUIC
29/// connections whose transport-level idle timeout already closed them are reclaimed by this
30/// bound even when the origin is never contacted again.
31const DEFAULT_H3_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
32
33/// Default idle threshold above which a pooled HTTP/2 connection is liveness-pinged before
34/// being handed out for a new request. Below this, we trust the connection without probing.
35const DEFAULT_H2_IDLE_PING_THRESHOLD: Duration = Duration::from_secs(10);
36
37/// Default timeout for the liveness PING — if we don't get an ACK within this window, the
38/// connection is treated as dead and a fresh one is established instead.
39const DEFAULT_H2_IDLE_PING_TIMEOUT: Duration = Duration::from_secs(20);
40
41const DEFAULT_MAX_BUFFERED_REQUEST_BODY: usize = 1024;
42
43/// Default time to wait for a `100 (Continue)` interim response before sending the request body
44/// anyway. Matches curl's fallback.
45const DEFAULT_EXPECT_CONTINUE_TIMEOUT: Duration = Duration::from_secs(1);
46
47/// An HTTP client supporting HTTP/1.x, HTTP/2 (via ALPN), and — when configured with a QUIC
48/// implementation — HTTP/3. See [`Client::new`] and [`Client::new_with_quic`] for construction
49/// information.
50#[derive(Clone, Debug, fieldwork::Fieldwork)]
51pub struct Client {
52 config: ArcedConnector,
53
54 #[field(vis = "pub(crate)", get)]
55 h3: Option<H3ClientState>,
56
57 #[field(vis = "pub(crate)", get)]
58 pool: Option<Pool<Origin, Box<dyn Transport>>>,
59
60 #[field(vis = "pub(crate)", get)]
61 h2_pool: Option<Pool<Origin, H2Pooled>>,
62
63 /// Maximum idle time for a pooled HTTP/1.1 connection before a background reaper closes it and
64 /// removes it from the pool. `None` disables expiry, restoring the previous behavior in which
65 /// idle h1 connections were retained until reused (and discarded by a liveness probe) or the
66 /// pool was manually cleaned up — an origin that stopped being contacted then held its idle
67 /// file descriptors indefinitely.
68 ///
69 /// Defaults to 5 minutes.
70 #[field(get, set, with, without, copy)]
71 h1_idle_timeout: Option<Duration>,
72
73 /// Maximum idle time for a pooled HTTP/2 connection. `None` disables expiry.
74 ///
75 /// Defaults to 5 minutes.
76 #[field(get, set, with, without, copy)]
77 h2_idle_timeout: Option<Duration>,
78
79 /// How long a pooled HTTP/3 connection is retained after it is established. An expired
80 /// connection is dropped from the pool — requests in flight on it are unaffected — and the
81 /// next request to its origin establishes a fresh connection. `None` disables expiry.
82 ///
83 /// Defaults to 5 minutes.
84 #[field(get, set, with, without, copy)]
85 h3_idle_timeout: Option<Duration>,
86
87 /// If a pooled HTTP/2 connection has been idle for longer than this, an active PING is
88 /// sent to verify it's still alive before being handed out. `None` disables the probe.
89 ///
90 /// Defaults to 10 seconds.
91 #[field(get, set, with, copy, without)]
92 h2_idle_ping_threshold: Option<Duration>,
93
94 /// Timeout for the liveness PING sent under the [`h2_idle_ping_threshold`] policy.
95 /// Connections whose ACK doesn't arrive within this window are treated as dead.
96 ///
97 /// Defaults to 20 seconds.
98 ///
99 /// [`h2_idle_ping_threshold`]: Self::h2_idle_ping_threshold
100 #[field(get, set, with, copy)]
101 h2_idle_ping_timeout: Duration,
102
103 /// url base for this client
104 #[field(get)]
105 base: Option<Arc<Url>>,
106
107 /// default request headers
108 #[field(get)]
109 default_headers: Arc<Headers>,
110
111 /// Request bodies of unknown length at or below this size are fully buffered before the
112 /// request head is written, letting the client send them with an accurate `Content-Length`.
113 /// Two consequences follow from being able to buffer: a small streaming body is framed with
114 /// `Content-Length` rather than `Transfer-Encoding: chunked`, and no `Expect: 100-continue`
115 /// handshake is used — there is no benefit to asking permission before sending a body we
116 /// have already buffered and can send in one shot. Larger or unbounded bodies stream
117 /// (chunked), and the `Expect: 100-continue` handshake applies to those. A known-length
118 /// body larger than this also uses `Expect: 100-continue`.
119 ///
120 /// Defaults to 1 KiB.
121 #[field(get, set, with, copy)]
122 max_buffered_request_body: usize,
123
124 /// How long to wait for a `100 (Continue)` interim response after sending a request head
125 /// carrying `Expect: 100-continue`, before sending the request body regardless.
126 ///
127 /// Per [RFC 9110 §10.1.1] a client must not wait indefinitely: a `100 (Continue)` cannot
128 /// traverse an HTTP/1.0 intermediary, and not every peer honors the expectation, so a client
129 /// that waited forever would deadlock against them. On timeout the body is sent anyway — the
130 /// same thing the client would do without the expectation.
131 ///
132 /// Defaults to 1 second.
133 ///
134 /// [RFC 9110 §10.1.1]: https://www.rfc-editor.org/rfc/rfc9110#section-10.1.1
135 #[field(get, set, with, copy)]
136 expect_continue_timeout: Duration,
137
138 /// optional per-request timeout
139 #[field(get, set, with, copy, without, option_set_some)]
140 timeout: Option<Duration>,
141
142 /// Default for [`Conn::strict_http_version`] on every conn this client builds.
143 ///
144 /// Off by default. When on, a request that cannot be carried by the protocol it was matched
145 /// to fails rather than being retried on an earlier protocol.
146 #[field(get, set, with, without, copy)]
147 strict_http_version: bool,
148
149 /// configuration
150 #[field(get, get_mut, set, with, into)]
151 context: Arc<HttpContext>,
152
153 /// type-erased middleware stack. Defaults to a no-op `()` handler. Set via
154 /// [`Client::with_handler`] / [`Client::set_handler`]; recover the concrete type via
155 /// [`Client::downcast_handler`].
156 #[field(vis = "pub(crate)", get = arc_handler)]
157 handler: ArcedClientHandler,
158
159 /// Encrypted-DNS resolver, if configured via [`Client::with_doh`]. When set, all DNS
160 /// resolution for this client is routed through it.
161 #[cfg(feature = "hickory")]
162 pub(crate) resolver: Option<crate::dns::Resolver>,
163}
164
165macro_rules! method {
166 ($fn_name:ident, $method:ident) => {
167 method!(
168 $fn_name,
169 $method,
170 concat!(
171 // yep, macro-generated doctests
172 "Builds a new client conn with the ",
173 stringify!($fn_name),
174 " http method and the provided url.
175
176```
177use trillium_client::{Client, Method};
178use trillium_testing::client_config;
179
180let client = Client::new(client_config());
181let conn = client.",
182 stringify!($fn_name),
183 "(\"http://localhost:8080/some/route\"); //<-
184
185assert_eq!(conn.method(), Method::",
186 stringify!($method),
187 ");
188assert_eq!(conn.url().to_string(), \"http://localhost:8080/some/route\");
189```
190"
191 )
192 );
193 };
194
195 ($fn_name:ident, $method:ident, $doc_comment:expr_2021) => {
196 #[doc = $doc_comment]
197 pub fn $fn_name(&self, url: impl IntoUrl) -> Conn {
198 self.build_conn(Method::$method, url)
199 }
200 };
201}
202
203pub(crate) fn default_request_headers() -> Headers {
204 Headers::new()
205 .with_inserted_header(KnownHeaderName::UserAgent, USER_AGENT)
206 .with_inserted_header(KnownHeaderName::Accept, "*/*")
207}
208
209impl Client {
210 method!(get, Get);
211
212 method!(post, Post);
213
214 method!(put, Put);
215
216 method!(delete, Delete);
217
218 method!(patch, Patch);
219
220 /// builds a new client from this `Connector`
221 pub fn new(connector: impl Connector) -> Self {
222 let config = ArcedConnector::new(connector);
223 let (pool, h2_pool) = (Pool::default(), Pool::default());
224 crate::reaper::spawn_pool_reaper(
225 config.runtime(),
226 pool.downgrade(),
227 h2_pool.downgrade(),
228 None,
229 );
230 Self {
231 config,
232 h3: None,
233 pool: Some(pool),
234 h2_pool: Some(h2_pool),
235 h1_idle_timeout: Some(DEFAULT_H1_IDLE_TIMEOUT),
236 h2_idle_timeout: Some(DEFAULT_H2_IDLE_TIMEOUT),
237 h3_idle_timeout: Some(DEFAULT_H3_IDLE_TIMEOUT),
238 h2_idle_ping_threshold: Some(DEFAULT_H2_IDLE_PING_THRESHOLD),
239 h2_idle_ping_timeout: DEFAULT_H2_IDLE_PING_TIMEOUT,
240 max_buffered_request_body: DEFAULT_MAX_BUFFERED_REQUEST_BODY,
241 expect_continue_timeout: DEFAULT_EXPECT_CONTINUE_TIMEOUT,
242 base: None,
243 default_headers: Arc::new(default_request_headers()),
244 timeout: None,
245 strict_http_version: false,
246 context: Default::default(),
247 handler: ArcedClientHandler::new(()),
248 #[cfg(feature = "hickory")]
249 resolver: None,
250 }
251 }
252
253 /// Build a new client with both a TCP connector and a QUIC connector for HTTP/3 support.
254 ///
255 /// The connector's runtime and UDP socket type are bound to the QUIC connector here,
256 /// before type erasure, so that `trillium-quinn` and the runtime adapter remain
257 /// independent crates that neither depends on the other.
258 ///
259 /// When H3 is configured, the client will track `Alt-Svc` headers in responses and
260 /// automatically use HTTP/3 for subsequent requests to origins that advertise it.
261 /// Other requests follow the standard h1 / h2-via-ALPN path.
262 pub fn new_with_quic<C: Connector, Q: QuicClientConfig<C>>(connector: C, quic: Q) -> Self {
263 // Bind the runtime into the QUIC client config before consuming `connector`.
264 let arced_quic = ArcedQuicClientConfig::new(&connector, quic);
265
266 #[cfg_attr(not(feature = "webtransport"), allow(unused_mut))]
267 let mut context = HttpContext::default();
268 #[cfg(feature = "webtransport")]
269 {
270 // Advertise WebTransport-over-h3 capability on outbound SETTINGS so a server can
271 // open server-initiated WT streams to us once a session is established.
272 // ENABLE_CONNECT_PROTOCOL is included for symmetry with the server side; harmless
273 // when the client never receives extended-CONNECT from the peer.
274 context
275 .config_mut()
276 .set_h3_datagrams_enabled(true)
277 .set_webtransport_enabled(true)
278 .set_extended_connect_enabled(true);
279 }
280
281 let config = ArcedConnector::new(connector);
282 let (pool, h2_pool) = (Pool::default(), Pool::default());
283 let h3 = H3ClientState::new(arced_quic);
284 crate::reaper::spawn_pool_reaper(
285 config.runtime(),
286 pool.downgrade(),
287 h2_pool.downgrade(),
288 Some(h3.pool.downgrade()),
289 );
290 Self {
291 config,
292 h3: Some(h3),
293 pool: Some(pool),
294 h2_pool: Some(h2_pool),
295 h1_idle_timeout: Some(DEFAULT_H1_IDLE_TIMEOUT),
296 h2_idle_timeout: Some(DEFAULT_H2_IDLE_TIMEOUT),
297 h3_idle_timeout: Some(DEFAULT_H3_IDLE_TIMEOUT),
298 h2_idle_ping_threshold: Some(DEFAULT_H2_IDLE_PING_THRESHOLD),
299 h2_idle_ping_timeout: DEFAULT_H2_IDLE_PING_TIMEOUT,
300 max_buffered_request_body: DEFAULT_MAX_BUFFERED_REQUEST_BODY,
301 expect_continue_timeout: DEFAULT_EXPECT_CONTINUE_TIMEOUT,
302 base: None,
303 default_headers: Arc::new(default_request_headers()),
304 timeout: None,
305 strict_http_version: false,
306 context: Arc::new(context),
307 handler: ArcedClientHandler::new(()),
308 #[cfg(feature = "hickory")]
309 resolver: None,
310 }
311 }
312
313 /// Install a [`ClientHandler`] middleware stack on this client.
314 ///
315 /// The handler runs around every request issued by this client: its `run` method fires before
316 /// the network round-trip (with the option to halt + synthesize a response), and its
317 /// `after_response` fires afterwards. Compose multiple handlers with tuples — see
318 /// [`ClientHandler`] for the lifecycle and `Vec`/tuple/`Option` impls.
319 ///
320 /// Returns `self` for chaining.
321 #[must_use]
322 pub fn with_handler<H: ClientHandler>(mut self, handler: H) -> Self {
323 self.set_handler(handler);
324 self
325 }
326
327 /// Install a [`ClientHandler`] middleware stack on this client. See [`Client::with_handler`]
328 /// for details.
329 pub fn set_handler<H: ClientHandler>(&mut self, handler: H) -> &mut Self {
330 self.handler = ArcedClientHandler::new(handler);
331 self
332 }
333
334 /// Borrow the type-erased [`ClientHandler`]
335 ///
336 /// See also [`Client::downcast_handler`] if you can name the type
337 pub fn handler(&self) -> &impl ClientHandler {
338 &self.handler
339 }
340
341 /// Borrow the installed [`ClientHandler`] as the concrete type `T`, returning `None` if the
342 /// installed handler is not of that type.
343 ///
344 /// Useful for inspecting handler-internal state from outside the request path — e.g., reading
345 /// counters from a metrics handler.
346 pub fn downcast_handler<T: Any + 'static>(&self) -> Option<&T> {
347 self.handler.downcast_ref()
348 }
349
350 /// chainable method to remove a header from default request headers
351 pub fn without_default_header(mut self, name: impl Into<HeaderName<'static>>) -> Self {
352 self.default_headers_mut().remove(name);
353 self
354 }
355
356 /// chainable method to insert a new default request header, replacing any existing value
357 pub fn with_default_header(
358 mut self,
359 name: impl Into<HeaderName<'static>>,
360 value: impl Into<HeaderValues>,
361 ) -> Self {
362 self.default_headers_mut().insert(name, value);
363 self
364 }
365
366 /// borrow the default headers mutably
367 ///
368 /// calling this will copy-on-write if the default headers are shared with another client clone
369 pub fn default_headers_mut(&mut self) -> &mut Headers {
370 Arc::make_mut(&mut self.default_headers)
371 }
372
373 /// chainable constructor to disable http/1.1 connection reuse.
374 ///
375 /// ```
376 /// use trillium_client::Client;
377 /// use trillium_smol::ClientConfig;
378 ///
379 /// let client = Client::new(ClientConfig::default()).without_keepalive();
380 /// ```
381 pub fn without_keepalive(mut self) -> Self {
382 self.pool = None;
383 self.h2_pool = None;
384 self
385 }
386
387 /// builds a new conn.
388 ///
389 /// if the client has pooling enabled and there is an available connection for this
390 /// origin (scheme + host + port), the new conn will reuse it when sent.
391 ///
392 /// ```
393 /// use trillium_client::{Client, Method};
394 /// use trillium_smol::ClientConfig;
395 /// let client = Client::new(ClientConfig::default());
396 ///
397 /// let conn = client.build_conn("get", "http://trillium.rs"); //<-
398 ///
399 /// assert_eq!(conn.method(), Method::Get);
400 /// assert_eq!(conn.url().host_str().unwrap(), "trillium.rs");
401 /// ```
402 pub fn build_conn<M>(&self, method: M, url: impl IntoUrl) -> Conn
403 where
404 M: TryInto<Method>,
405 <M as TryInto<Method>>::Error: Debug,
406 {
407 let method = method.try_into().unwrap();
408 let (url, request_target, error) = if let Some(base) = &self.base
409 && let Some(request_target) = url.request_target(method)
410 {
411 ((**base).clone(), Some(request_target), None)
412 } else {
413 match self.build_url(url) {
414 Ok(url) => (url, None, None),
415 // `build_conn` is infallible by contract, so a malformed url is
416 // deferred rather than panicked: stash the error (surfaced at the
417 // top of `Conn::exec`) behind a placeholder url that never dials.
418 Err(error) => (
419 Url::parse("http://invalid.invalid/").expect("literal is a valid url"),
420 None,
421 Some(error),
422 ),
423 }
424 };
425
426 Conn {
427 url,
428 method,
429 request_headers: Headers::clone(&self.default_headers),
430 response_headers: Headers::new(),
431 transport: None,
432 status: None,
433 request_body: None,
434 request_body_fully_buffered: false,
435 protocol_session: ProtocolSession::Http1,
436 #[cfg(feature = "webtransport")]
437 wt_pool_entry: None,
438 buffer: Vec::with_capacity(128).into(),
439 response_body_state: ReceivedBodyState::End,
440 headers_finalized: false,
441 halted: false,
442 error,
443 body_override: None,
444 timeout: self.timeout,
445 http_version: None,
446 strict_http_version: self.strict_http_version,
447 state: TypeSet::new(),
448 context: self.context.clone(),
449 authority: None,
450 scheme: None,
451 path: None,
452 request_target,
453 protocol: None,
454 request_trailers: None,
455 response_trailers: None,
456 client: self.clone(),
457 followup: None,
458 upgrade: false,
459 }
460 }
461
462 /// borrow the connector for this client
463 pub fn connector(&self) -> &ArcedConnector {
464 &self.config
465 }
466
467 /// The pool implementation accumulates a small memory footprint for each new host. If
468 /// your application is reusing a pool against a large number of unique hosts, call this
469 /// method intermittently.
470 pub fn clean_up_pool(&self) {
471 if let Some(pool) = &self.pool {
472 pool.reap();
473 }
474 if let Some(h2_pool) = &self.h2_pool {
475 h2_pool.reap();
476 }
477 }
478
479 /// chainable method to set the base for this client
480 pub fn with_base(mut self, base: impl IntoUrl) -> Self {
481 self.set_base(base).unwrap();
482 self
483 }
484
485 /// attempt to build a url from this IntoUrl and the [`Client::base`], if set
486 pub fn build_url(&self, url: impl IntoUrl) -> crate::Result<Url> {
487 url.into_url(self.base())
488 }
489
490 /// set the base for this client
491 pub fn set_base(&mut self, base: impl IntoUrl) -> crate::Result<()> {
492 let mut base = base.into_url(None)?;
493
494 if !base.path().ends_with('/') {
495 log::warn!("appending a trailing / to {base}");
496 base.set_path(&format!("{}/", base.path()));
497 }
498
499 self.base = Some(Arc::new(base));
500 Ok(())
501 }
502
503 /// Mutate the url base for this client.
504 ///
505 /// This has "clone-on-write" semantics if there are other clones of this client. If there are
506 /// other clones of this client, they will not be updated.
507 pub fn base_mut(&mut self) -> Option<&mut Url> {
508 let base = self.base.as_mut()?;
509 Some(Arc::make_mut(base))
510 }
511}
512
513impl<T: Connector> From<T> for Client {
514 fn from(connector: T) -> Self {
515 Self::new(connector)
516 }
517}