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