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)]
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 installed [`ClientHandler`] as the concrete type `T`, returning `None` if the
228    /// installed handler is not of that type.
229    ///
230    /// Useful for inspecting handler-internal state from outside the request path — e.g., reading
231    /// counters from a metrics handler.
232    pub fn downcast_handler<T: Any + 'static>(&self) -> Option<&T> {
233        self.handler.downcast_ref()
234    }
235
236    /// chainable method to remove a header from default request headers
237    pub fn without_default_header(mut self, name: impl Into<HeaderName<'static>>) -> Self {
238        self.default_headers_mut().remove(name);
239        self
240    }
241
242    /// chainable method to insert a new default request header, replacing any existing value
243    pub fn with_default_header(
244        mut self,
245        name: impl Into<HeaderName<'static>>,
246        value: impl Into<HeaderValues>,
247    ) -> Self {
248        self.default_headers_mut().insert(name, value);
249        self
250    }
251
252    /// borrow the default headers mutably
253    ///
254    /// calling this will copy-on-write if the default headers are shared with another client clone
255    pub fn default_headers_mut(&mut self) -> &mut Headers {
256        Arc::make_mut(&mut self.default_headers)
257    }
258
259    /// chainable constructor to disable http/1.1 connection reuse.
260    ///
261    /// ```
262    /// use trillium_client::Client;
263    /// use trillium_smol::ClientConfig;
264    ///
265    /// let client = Client::new(ClientConfig::default()).without_keepalive();
266    /// ```
267    pub fn without_keepalive(mut self) -> Self {
268        self.pool = None;
269        self.h2_pool = None;
270        self
271    }
272
273    /// builds a new conn.
274    ///
275    /// if the client has pooling enabled and there is an available connection for this
276    /// origin (scheme + host + port), the new conn will reuse it when sent.
277    ///
278    /// ```
279    /// use trillium_client::{Client, Method};
280    /// use trillium_smol::ClientConfig;
281    /// let client = Client::new(ClientConfig::default());
282    ///
283    /// let conn = client.build_conn("get", "http://trillium.rs"); //<-
284    ///
285    /// assert_eq!(conn.method(), Method::Get);
286    /// assert_eq!(conn.url().host_str().unwrap(), "trillium.rs");
287    /// ```
288    pub fn build_conn<M>(&self, method: M, url: impl IntoUrl) -> Conn
289    where
290        M: TryInto<Method>,
291        <M as TryInto<Method>>::Error: Debug,
292    {
293        let method = method.try_into().unwrap();
294        let (url, request_target) = if let Some(base) = &self.base
295            && let Some(request_target) = url.request_target(method)
296        {
297            ((**base).clone(), Some(request_target))
298        } else {
299            (self.build_url(url).unwrap(), None)
300        };
301
302        Conn {
303            url,
304            method,
305            request_headers: Headers::clone(&self.default_headers),
306            response_headers: Headers::new(),
307            transport: None,
308            status: None,
309            request_body: None,
310            protocol_session: ProtocolSession::Http1,
311            #[cfg(feature = "webtransport")]
312            wt_pool_entry: None,
313            buffer: Vec::with_capacity(128).into(),
314            response_body_state: ReceivedBodyState::Start,
315            headers_finalized: false,
316            halted: false,
317            error: None,
318            body_override: None,
319            timeout: self.timeout,
320            http_version: Http1_1,
321            max_head_length: 8 * 1024,
322            state: TypeSet::new(),
323            context: self.context.clone(),
324            authority: None,
325            scheme: None,
326            path: None,
327            request_target,
328            protocol: None,
329            request_trailers: None,
330            response_trailers: None,
331            client: self.clone(),
332            followup: None,
333        }
334    }
335
336    /// borrow the connector for this client
337    pub fn connector(&self) -> &ArcedConnector {
338        &self.config
339    }
340
341    /// The pool implementation accumulates a small memory footprint for each new host. If
342    /// your application is reusing a pool against a large number of unique hosts, call this
343    /// method intermittently.
344    pub fn clean_up_pool(&self) {
345        if let Some(pool) = &self.pool {
346            pool.cleanup();
347        }
348        if let Some(h2_pool) = &self.h2_pool {
349            h2_pool.cleanup();
350        }
351    }
352
353    /// chainable method to set the base for this client
354    pub fn with_base(mut self, base: impl IntoUrl) -> Self {
355        self.set_base(base).unwrap();
356        self
357    }
358
359    /// attempt to build a url from this IntoUrl and the [`Client::base`], if set
360    pub fn build_url(&self, url: impl IntoUrl) -> crate::Result<Url> {
361        url.into_url(self.base())
362    }
363
364    /// set the base for this client
365    pub fn set_base(&mut self, base: impl IntoUrl) -> crate::Result<()> {
366        let mut base = base.into_url(None)?;
367
368        if !base.path().ends_with('/') {
369            log::warn!("appending a trailing / to {base}");
370            base.set_path(&format!("{}/", base.path()));
371        }
372
373        self.base = Some(Arc::new(base));
374        Ok(())
375    }
376
377    /// Mutate the url base for this client.
378    ///
379    /// This has "clone-on-write" semantics if there are other clones of this client. If there are
380    /// other clones of this client, they will not be updated.
381    pub fn base_mut(&mut self) -> Option<&mut Url> {
382        let base = self.base.as_mut()?;
383        Some(Arc::make_mut(base))
384    }
385}
386
387impl<T: Connector> From<T> for Client {
388    fn from(connector: T) -> Self {
389        Self::new(connector)
390    }
391}