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    /// 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}