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