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, Version::Http1_1,
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
28/// An HTTP client supporting HTTP/1.x, HTTP/2 (via ALPN), and — when configured with a QUIC
29/// implementation — HTTP/3. See [`Client::new`] and [`Client::new_with_quic`] for construction
30/// information.
31#[derive(Clone, Debug, fieldwork::Fieldwork)]
32pub struct Client {
33    config: ArcedConnector,
34
35    #[field(vis = "pub(crate)", get)]
36    h3: Option<H3ClientState>,
37
38    #[field(vis = "pub(crate)", get)]
39    pool: Option<Pool<Origin, Box<dyn Transport>>>,
40
41    #[field(vis = "pub(crate)", get)]
42    h2_pool: Option<Pool<Origin, H2Pooled>>,
43
44    /// Maximum idle time for a pooled HTTP/2 connection. `None` disables expiry.
45    ///
46    /// Defaults to 5 minutes.
47    #[field(get, set, with, without, copy)]
48    h2_idle_timeout: Option<Duration>,
49
50    /// If a pooled HTTP/2 connection has been idle for longer than this, an active PING is
51    /// sent to verify it's still alive before being handed out. `None` disables the probe.
52    ///
53    /// Defaults to 10 seconds.
54    #[field(get, set, with, copy, without)]
55    h2_idle_ping_threshold: Option<Duration>,
56
57    /// Timeout for the liveness PING sent under the [`h2_idle_ping_threshold`] policy.
58    /// Connections whose ACK doesn't arrive within this window are treated as dead.
59    ///
60    /// Defaults to 20 seconds.
61    ///
62    /// [`h2_idle_ping_threshold`]: Self::h2_idle_ping_threshold
63    #[field(get, set, with, copy)]
64    h2_idle_ping_timeout: Duration,
65
66    /// url base for this client
67    #[field(get)]
68    base: Option<Arc<Url>>,
69
70    /// default request headers
71    #[field(get)]
72    default_headers: Arc<Headers>,
73
74    /// optional per-request timeout
75    #[field(get, set, with, copy, without, option_set_some)]
76    timeout: Option<Duration>,
77
78    /// configuration
79    #[field(get, get_mut, set, with, into)]
80    context: Arc<HttpContext>,
81
82    /// type-erased middleware stack. Defaults to a no-op `()` handler. Set via
83    /// [`Client::with_handler`] / [`Client::set_handler`]; recover the concrete type via
84    /// [`Client::downcast_handler`].
85    #[field(vis = "pub(crate)", get = arc_handler)]
86    handler: ArcedClientHandler,
87}
88
89macro_rules! method {
90    ($fn_name:ident, $method:ident) => {
91        method!(
92            $fn_name,
93            $method,
94            concat!(
95                // yep, macro-generated doctests
96                "Builds a new client conn with the ",
97                stringify!($fn_name),
98                " http method and the provided url.
99
100```
101use trillium_client::{Client, Method};
102use trillium_testing::client_config;
103
104let client = Client::new(client_config());
105let conn = client.",
106                stringify!($fn_name),
107                "(\"http://localhost:8080/some/route\"); //<-
108
109assert_eq!(conn.method(), Method::",
110                stringify!($method),
111                ");
112assert_eq!(conn.url().to_string(), \"http://localhost:8080/some/route\");
113```
114"
115            )
116        );
117    };
118
119    ($fn_name:ident, $method:ident, $doc_comment:expr_2021) => {
120        #[doc = $doc_comment]
121        pub fn $fn_name(&self, url: impl IntoUrl) -> Conn {
122            self.build_conn(Method::$method, url)
123        }
124    };
125}
126
127pub(crate) fn default_request_headers() -> Headers {
128    Headers::new()
129        .with_inserted_header(KnownHeaderName::UserAgent, USER_AGENT)
130        .with_inserted_header(KnownHeaderName::Accept, "*/*")
131}
132
133impl Client {
134    method!(get, Get);
135
136    method!(post, Post);
137
138    method!(put, Put);
139
140    method!(delete, Delete);
141
142    method!(patch, Patch);
143
144    /// builds a new client from this `Connector`
145    pub fn new(connector: impl Connector) -> Self {
146        Self {
147            config: ArcedConnector::new(connector),
148            h3: None,
149            pool: Some(Pool::default()),
150            h2_pool: Some(Pool::default()),
151            h2_idle_timeout: Some(DEFAULT_H2_IDLE_TIMEOUT),
152            h2_idle_ping_threshold: Some(DEFAULT_H2_IDLE_PING_THRESHOLD),
153            h2_idle_ping_timeout: DEFAULT_H2_IDLE_PING_TIMEOUT,
154            base: None,
155            default_headers: Arc::new(default_request_headers()),
156            timeout: None,
157            context: Default::default(),
158            handler: ArcedClientHandler::new(()),
159        }
160    }
161
162    /// Build a new client with both a TCP connector and a QUIC connector for HTTP/3 support.
163    ///
164    /// The connector's runtime and UDP socket type are bound to the QUIC connector here,
165    /// before type erasure, so that `trillium-quinn` and the runtime adapter remain
166    /// independent crates that neither depends on the other.
167    ///
168    /// When H3 is configured, the client will track `Alt-Svc` headers in responses and
169    /// automatically use HTTP/3 for subsequent requests to origins that advertise it.
170    /// Other requests follow the standard h1 / h2-via-ALPN path.
171    pub fn new_with_quic<C: Connector, Q: QuicClientConfig<C>>(connector: C, quic: Q) -> Self {
172        // Bind the runtime into the QUIC client config before consuming `connector`.
173        let arced_quic = ArcedQuicClientConfig::new(&connector, quic);
174
175        #[cfg_attr(not(feature = "webtransport"), allow(unused_mut))]
176        let mut context = HttpContext::default();
177        #[cfg(feature = "webtransport")]
178        {
179            // Advertise WebTransport-over-h3 capability on outbound SETTINGS so a server can
180            // open server-initiated WT streams to us once a session is established.
181            // ENABLE_CONNECT_PROTOCOL is included for symmetry with the server side; harmless
182            // when the client never receives extended-CONNECT from the peer.
183            context
184                .config_mut()
185                .set_h3_datagrams_enabled(true)
186                .set_webtransport_enabled(true)
187                .set_extended_connect_enabled(true);
188        }
189
190        Self {
191            config: ArcedConnector::new(connector),
192            h3: Some(H3ClientState::new(arced_quic)),
193            pool: Some(Pool::default()),
194            h2_pool: Some(Pool::default()),
195            h2_idle_timeout: Some(DEFAULT_H2_IDLE_TIMEOUT),
196            h2_idle_ping_threshold: Some(DEFAULT_H2_IDLE_PING_THRESHOLD),
197            h2_idle_ping_timeout: DEFAULT_H2_IDLE_PING_TIMEOUT,
198            base: None,
199            default_headers: Arc::new(default_request_headers()),
200            timeout: None,
201            context: Arc::new(context),
202            handler: ArcedClientHandler::new(()),
203        }
204    }
205
206    /// Install a [`ClientHandler`] middleware stack on this client.
207    ///
208    /// The handler runs around every request issued by this client: its `run` method fires before
209    /// the network round-trip (with the option to halt + synthesize a response), and its
210    /// `after_response` fires afterwards. Compose multiple handlers with tuples — see
211    /// [`ClientHandler`] for the lifecycle and `Vec`/tuple/`Option` impls.
212    ///
213    /// Returns `self` for chaining.
214    #[must_use]
215    pub fn with_handler<H: ClientHandler>(mut self, handler: H) -> Self {
216        self.set_handler(handler);
217        self
218    }
219
220    /// Install a [`ClientHandler`] middleware stack on this client. See [`Client::with_handler`]
221    /// for details.
222    pub fn set_handler<H: ClientHandler>(&mut self, handler: H) -> &mut Self {
223        self.handler = ArcedClientHandler::new(handler);
224        self
225    }
226
227    /// Borrow the type-erased [`ClientHandler`]
228    ///
229    /// See also [`Client::downcast_handler`] if you can name the type
230    pub fn handler(&self) -> &impl ClientHandler {
231        &self.handler
232    }
233
234    /// Borrow the installed [`ClientHandler`] as the concrete type `T`, returning `None` if the
235    /// installed handler is not of that type.
236    ///
237    /// Useful for inspecting handler-internal state from outside the request path — e.g., reading
238    /// counters from a metrics handler.
239    pub fn downcast_handler<T: Any + 'static>(&self) -> Option<&T> {
240        self.handler.downcast_ref()
241    }
242
243    /// chainable method to remove a header from default request headers
244    pub fn without_default_header(mut self, name: impl Into<HeaderName<'static>>) -> Self {
245        self.default_headers_mut().remove(name);
246        self
247    }
248
249    /// chainable method to insert a new default request header, replacing any existing value
250    pub fn with_default_header(
251        mut self,
252        name: impl Into<HeaderName<'static>>,
253        value: impl Into<HeaderValues>,
254    ) -> Self {
255        self.default_headers_mut().insert(name, value);
256        self
257    }
258
259    /// borrow the default headers mutably
260    ///
261    /// calling this will copy-on-write if the default headers are shared with another client clone
262    pub fn default_headers_mut(&mut self) -> &mut Headers {
263        Arc::make_mut(&mut self.default_headers)
264    }
265
266    /// chainable constructor to disable http/1.1 connection reuse.
267    ///
268    /// ```
269    /// use trillium_client::Client;
270    /// use trillium_smol::ClientConfig;
271    ///
272    /// let client = Client::new(ClientConfig::default()).without_keepalive();
273    /// ```
274    pub fn without_keepalive(mut self) -> Self {
275        self.pool = None;
276        self.h2_pool = None;
277        self
278    }
279
280    /// builds a new conn.
281    ///
282    /// if the client has pooling enabled and there is an available connection for this
283    /// origin (scheme + host + port), the new conn will reuse it when sent.
284    ///
285    /// ```
286    /// use trillium_client::{Client, Method};
287    /// use trillium_smol::ClientConfig;
288    /// let client = Client::new(ClientConfig::default());
289    ///
290    /// let conn = client.build_conn("get", "http://trillium.rs"); //<-
291    ///
292    /// assert_eq!(conn.method(), Method::Get);
293    /// assert_eq!(conn.url().host_str().unwrap(), "trillium.rs");
294    /// ```
295    pub fn build_conn<M>(&self, method: M, url: impl IntoUrl) -> Conn
296    where
297        M: TryInto<Method>,
298        <M as TryInto<Method>>::Error: Debug,
299    {
300        let method = method.try_into().unwrap();
301        let (url, request_target) = if let Some(base) = &self.base
302            && let Some(request_target) = url.request_target(method)
303        {
304            ((**base).clone(), Some(request_target))
305        } else {
306            (self.build_url(url).unwrap(), None)
307        };
308
309        Conn {
310            url,
311            method,
312            request_headers: Headers::clone(&self.default_headers),
313            response_headers: Headers::new(),
314            transport: None,
315            status: None,
316            request_body: None,
317            protocol_session: ProtocolSession::Http1,
318            #[cfg(feature = "webtransport")]
319            wt_pool_entry: None,
320            buffer: Vec::with_capacity(128).into(),
321            response_body_state: ReceivedBodyState::End,
322            headers_finalized: false,
323            halted: false,
324            error: None,
325            body_override: None,
326            timeout: self.timeout,
327            http_version: Http1_1,
328            max_head_length: 8 * 1024,
329            state: TypeSet::new(),
330            context: self.context.clone(),
331            authority: None,
332            scheme: None,
333            path: None,
334            request_target,
335            protocol: None,
336            request_trailers: None,
337            response_trailers: None,
338            client: self.clone(),
339            followup: None,
340            upgrade: false,
341        }
342    }
343
344    /// borrow the connector for this client
345    pub fn connector(&self) -> &ArcedConnector {
346        &self.config
347    }
348
349    /// The pool implementation accumulates a small memory footprint for each new host. If
350    /// your application is reusing a pool against a large number of unique hosts, call this
351    /// method intermittently.
352    pub fn clean_up_pool(&self) {
353        if let Some(pool) = &self.pool {
354            pool.cleanup();
355        }
356        if let Some(h2_pool) = &self.h2_pool {
357            h2_pool.cleanup();
358        }
359    }
360
361    /// chainable method to set the base for this client
362    pub fn with_base(mut self, base: impl IntoUrl) -> Self {
363        self.set_base(base).unwrap();
364        self
365    }
366
367    /// attempt to build a url from this IntoUrl and the [`Client::base`], if set
368    pub fn build_url(&self, url: impl IntoUrl) -> crate::Result<Url> {
369        url.into_url(self.base())
370    }
371
372    /// set the base for this client
373    pub fn set_base(&mut self, base: impl IntoUrl) -> crate::Result<()> {
374        let mut base = base.into_url(None)?;
375
376        if !base.path().ends_with('/') {
377            log::warn!("appending a trailing / to {base}");
378            base.set_path(&format!("{}/", base.path()));
379        }
380
381        self.base = Some(Arc::new(base));
382        Ok(())
383    }
384
385    /// Mutate the url base for this client.
386    ///
387    /// This has "clone-on-write" semantics if there are other clones of this client. If there are
388    /// other clones of this client, they will not be updated.
389    pub fn base_mut(&mut self) -> Option<&mut Url> {
390        let base = self.base.as_mut()?;
391        Some(Arc::make_mut(base))
392    }
393}
394
395impl<T: Connector> From<T> for Client {
396    fn from(connector: T) -> Self {
397        Self::new(connector)
398    }
399}