Skip to main content

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}