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 /// configuration
143 #[field(get, get_mut, set, with, into)]
144 context: Arc<HttpContext>,
145
146 /// type-erased middleware stack. Defaults to a no-op `()` handler. Set via
147 /// [`Client::with_handler`] / [`Client::set_handler`]; recover the concrete type via
148 /// [`Client::downcast_handler`].
149 #[field(vis = "pub(crate)", get = arc_handler)]
150 handler: ArcedClientHandler,
151
152 /// Encrypted-DNS resolver, if configured via [`Client::with_doh`]. When set, all DNS
153 /// resolution for this client is routed through it.
154 #[cfg(feature = "hickory")]
155 pub(crate) resolver: Option<crate::dns::Resolver>,
156}
157
158macro_rules! method {
159 ($fn_name:ident, $method:ident) => {
160 method!(
161 $fn_name,
162 $method,
163 concat!(
164 // yep, macro-generated doctests
165 "Builds a new client conn with the ",
166 stringify!($fn_name),
167 " http method and the provided url.
168
169```
170use trillium_client::{Client, Method};
171use trillium_testing::client_config;
172
173let client = Client::new(client_config());
174let conn = client.",
175 stringify!($fn_name),
176 "(\"http://localhost:8080/some/route\"); //<-
177
178assert_eq!(conn.method(), Method::",
179 stringify!($method),
180 ");
181assert_eq!(conn.url().to_string(), \"http://localhost:8080/some/route\");
182```
183"
184 )
185 );
186 };
187
188 ($fn_name:ident, $method:ident, $doc_comment:expr_2021) => {
189 #[doc = $doc_comment]
190 pub fn $fn_name(&self, url: impl IntoUrl) -> Conn {
191 self.build_conn(Method::$method, url)
192 }
193 };
194}
195
196pub(crate) fn default_request_headers() -> Headers {
197 Headers::new()
198 .with_inserted_header(KnownHeaderName::UserAgent, USER_AGENT)
199 .with_inserted_header(KnownHeaderName::Accept, "*/*")
200}
201
202impl Client {
203 method!(get, Get);
204
205 method!(post, Post);
206
207 method!(put, Put);
208
209 method!(delete, Delete);
210
211 method!(patch, Patch);
212
213 /// builds a new client from this `Connector`
214 pub fn new(connector: impl Connector) -> Self {
215 let config = ArcedConnector::new(connector);
216 let (pool, h2_pool) = (Pool::default(), Pool::default());
217 crate::reaper::spawn_pool_reaper(
218 config.runtime(),
219 pool.downgrade(),
220 h2_pool.downgrade(),
221 None,
222 );
223 Self {
224 config,
225 h3: None,
226 pool: Some(pool),
227 h2_pool: Some(h2_pool),
228 h1_idle_timeout: Some(DEFAULT_H1_IDLE_TIMEOUT),
229 h2_idle_timeout: Some(DEFAULT_H2_IDLE_TIMEOUT),
230 h3_idle_timeout: Some(DEFAULT_H3_IDLE_TIMEOUT),
231 h2_idle_ping_threshold: Some(DEFAULT_H2_IDLE_PING_THRESHOLD),
232 h2_idle_ping_timeout: DEFAULT_H2_IDLE_PING_TIMEOUT,
233 max_buffered_request_body: DEFAULT_MAX_BUFFERED_REQUEST_BODY,
234 expect_continue_timeout: DEFAULT_EXPECT_CONTINUE_TIMEOUT,
235 base: None,
236 default_headers: Arc::new(default_request_headers()),
237 timeout: None,
238 context: Default::default(),
239 handler: ArcedClientHandler::new(()),
240 #[cfg(feature = "hickory")]
241 resolver: None,
242 }
243 }
244
245 /// Build a new client with both a TCP connector and a QUIC connector for HTTP/3 support.
246 ///
247 /// The connector's runtime and UDP socket type are bound to the QUIC connector here,
248 /// before type erasure, so that `trillium-quinn` and the runtime adapter remain
249 /// independent crates that neither depends on the other.
250 ///
251 /// When H3 is configured, the client will track `Alt-Svc` headers in responses and
252 /// automatically use HTTP/3 for subsequent requests to origins that advertise it.
253 /// Other requests follow the standard h1 / h2-via-ALPN path.
254 pub fn new_with_quic<C: Connector, Q: QuicClientConfig<C>>(connector: C, quic: Q) -> Self {
255 // Bind the runtime into the QUIC client config before consuming `connector`.
256 let arced_quic = ArcedQuicClientConfig::new(&connector, quic);
257
258 #[cfg_attr(not(feature = "webtransport"), allow(unused_mut))]
259 let mut context = HttpContext::default();
260 #[cfg(feature = "webtransport")]
261 {
262 // Advertise WebTransport-over-h3 capability on outbound SETTINGS so a server can
263 // open server-initiated WT streams to us once a session is established.
264 // ENABLE_CONNECT_PROTOCOL is included for symmetry with the server side; harmless
265 // when the client never receives extended-CONNECT from the peer.
266 context
267 .config_mut()
268 .set_h3_datagrams_enabled(true)
269 .set_webtransport_enabled(true)
270 .set_extended_connect_enabled(true);
271 }
272
273 let config = ArcedConnector::new(connector);
274 let (pool, h2_pool) = (Pool::default(), Pool::default());
275 let h3 = H3ClientState::new(arced_quic);
276 crate::reaper::spawn_pool_reaper(
277 config.runtime(),
278 pool.downgrade(),
279 h2_pool.downgrade(),
280 Some(h3.pool.downgrade()),
281 );
282 Self {
283 config,
284 h3: Some(h3),
285 pool: Some(pool),
286 h2_pool: Some(h2_pool),
287 h1_idle_timeout: Some(DEFAULT_H1_IDLE_TIMEOUT),
288 h2_idle_timeout: Some(DEFAULT_H2_IDLE_TIMEOUT),
289 h3_idle_timeout: Some(DEFAULT_H3_IDLE_TIMEOUT),
290 h2_idle_ping_threshold: Some(DEFAULT_H2_IDLE_PING_THRESHOLD),
291 h2_idle_ping_timeout: DEFAULT_H2_IDLE_PING_TIMEOUT,
292 max_buffered_request_body: DEFAULT_MAX_BUFFERED_REQUEST_BODY,
293 expect_continue_timeout: DEFAULT_EXPECT_CONTINUE_TIMEOUT,
294 base: None,
295 default_headers: Arc::new(default_request_headers()),
296 timeout: None,
297 context: Arc::new(context),
298 handler: ArcedClientHandler::new(()),
299 #[cfg(feature = "hickory")]
300 resolver: None,
301 }
302 }
303
304 /// Install a [`ClientHandler`] middleware stack on this client.
305 ///
306 /// The handler runs around every request issued by this client: its `run` method fires before
307 /// the network round-trip (with the option to halt + synthesize a response), and its
308 /// `after_response` fires afterwards. Compose multiple handlers with tuples — see
309 /// [`ClientHandler`] for the lifecycle and `Vec`/tuple/`Option` impls.
310 ///
311 /// Returns `self` for chaining.
312 #[must_use]
313 pub fn with_handler<H: ClientHandler>(mut self, handler: H) -> Self {
314 self.set_handler(handler);
315 self
316 }
317
318 /// Install a [`ClientHandler`] middleware stack on this client. See [`Client::with_handler`]
319 /// for details.
320 pub fn set_handler<H: ClientHandler>(&mut self, handler: H) -> &mut Self {
321 self.handler = ArcedClientHandler::new(handler);
322 self
323 }
324
325 /// Borrow the type-erased [`ClientHandler`]
326 ///
327 /// See also [`Client::downcast_handler`] if you can name the type
328 pub fn handler(&self) -> &impl ClientHandler {
329 &self.handler
330 }
331
332 /// Borrow the installed [`ClientHandler`] as the concrete type `T`, returning `None` if the
333 /// installed handler is not of that type.
334 ///
335 /// Useful for inspecting handler-internal state from outside the request path — e.g., reading
336 /// counters from a metrics handler.
337 pub fn downcast_handler<T: Any + 'static>(&self) -> Option<&T> {
338 self.handler.downcast_ref()
339 }
340
341 /// chainable method to remove a header from default request headers
342 pub fn without_default_header(mut self, name: impl Into<HeaderName<'static>>) -> Self {
343 self.default_headers_mut().remove(name);
344 self
345 }
346
347 /// chainable method to insert a new default request header, replacing any existing value
348 pub fn with_default_header(
349 mut self,
350 name: impl Into<HeaderName<'static>>,
351 value: impl Into<HeaderValues>,
352 ) -> Self {
353 self.default_headers_mut().insert(name, value);
354 self
355 }
356
357 /// borrow the default headers mutably
358 ///
359 /// calling this will copy-on-write if the default headers are shared with another client clone
360 pub fn default_headers_mut(&mut self) -> &mut Headers {
361 Arc::make_mut(&mut self.default_headers)
362 }
363
364 /// chainable constructor to disable http/1.1 connection reuse.
365 ///
366 /// ```
367 /// use trillium_client::Client;
368 /// use trillium_smol::ClientConfig;
369 ///
370 /// let client = Client::new(ClientConfig::default()).without_keepalive();
371 /// ```
372 pub fn without_keepalive(mut self) -> Self {
373 self.pool = None;
374 self.h2_pool = None;
375 self
376 }
377
378 /// builds a new conn.
379 ///
380 /// if the client has pooling enabled and there is an available connection for this
381 /// origin (scheme + host + port), the new conn will reuse it when sent.
382 ///
383 /// ```
384 /// use trillium_client::{Client, Method};
385 /// use trillium_smol::ClientConfig;
386 /// let client = Client::new(ClientConfig::default());
387 ///
388 /// let conn = client.build_conn("get", "http://trillium.rs"); //<-
389 ///
390 /// assert_eq!(conn.method(), Method::Get);
391 /// assert_eq!(conn.url().host_str().unwrap(), "trillium.rs");
392 /// ```
393 pub fn build_conn<M>(&self, method: M, url: impl IntoUrl) -> Conn
394 where
395 M: TryInto<Method>,
396 <M as TryInto<Method>>::Error: Debug,
397 {
398 let method = method.try_into().unwrap();
399 let (url, request_target, error) = if let Some(base) = &self.base
400 && let Some(request_target) = url.request_target(method)
401 {
402 ((**base).clone(), Some(request_target), None)
403 } else {
404 match self.build_url(url) {
405 Ok(url) => (url, None, None),
406 // `build_conn` is infallible by contract, so a malformed url is
407 // deferred rather than panicked: stash the error (surfaced at the
408 // top of `Conn::exec`) behind a placeholder url that never dials.
409 Err(error) => (
410 Url::parse("http://invalid.invalid/").expect("literal is a valid url"),
411 None,
412 Some(error),
413 ),
414 }
415 };
416
417 Conn {
418 url,
419 method,
420 request_headers: Headers::clone(&self.default_headers),
421 response_headers: Headers::new(),
422 transport: None,
423 status: None,
424 request_body: None,
425 request_body_fully_buffered: false,
426 protocol_session: ProtocolSession::Http1,
427 #[cfg(feature = "webtransport")]
428 wt_pool_entry: None,
429 buffer: Vec::with_capacity(128).into(),
430 response_body_state: ReceivedBodyState::End,
431 headers_finalized: false,
432 halted: false,
433 error,
434 body_override: None,
435 timeout: self.timeout,
436 http_version: None,
437 max_head_length: 8 * 1024,
438 state: TypeSet::new(),
439 context: self.context.clone(),
440 authority: None,
441 scheme: None,
442 path: None,
443 request_target,
444 protocol: None,
445 request_trailers: None,
446 response_trailers: None,
447 client: self.clone(),
448 followup: None,
449 upgrade: false,
450 }
451 }
452
453 /// borrow the connector for this client
454 pub fn connector(&self) -> &ArcedConnector {
455 &self.config
456 }
457
458 /// The pool implementation accumulates a small memory footprint for each new host. If
459 /// your application is reusing a pool against a large number of unique hosts, call this
460 /// method intermittently.
461 pub fn clean_up_pool(&self) {
462 if let Some(pool) = &self.pool {
463 pool.reap();
464 }
465 if let Some(h2_pool) = &self.h2_pool {
466 h2_pool.reap();
467 }
468 }
469
470 /// chainable method to set the base for this client
471 pub fn with_base(mut self, base: impl IntoUrl) -> Self {
472 self.set_base(base).unwrap();
473 self
474 }
475
476 /// attempt to build a url from this IntoUrl and the [`Client::base`], if set
477 pub fn build_url(&self, url: impl IntoUrl) -> crate::Result<Url> {
478 url.into_url(self.base())
479 }
480
481 /// set the base for this client
482 pub fn set_base(&mut self, base: impl IntoUrl) -> crate::Result<()> {
483 let mut base = base.into_url(None)?;
484
485 if !base.path().ends_with('/') {
486 log::warn!("appending a trailing / to {base}");
487 base.set_path(&format!("{}/", base.path()));
488 }
489
490 self.base = Some(Arc::new(base));
491 Ok(())
492 }
493
494 /// Mutate the url base for this client.
495 ///
496 /// This has "clone-on-write" semantics if there are other clones of this client. If there are
497 /// other clones of this client, they will not be updated.
498 pub fn base_mut(&mut self) -> Option<&mut Url> {
499 let base = self.base.as_mut()?;
500 Some(Arc::make_mut(base))
501 }
502}
503
504impl<T: Connector> From<T> for Client {
505 fn from(connector: T) -> Self {
506 Self::new(connector)
507 }
508}