Skip to main content

trillium_client/
client.rs

1use crate::{Conn, IntoUrl, Pool, USER_AGENT, conn::H2Pooled, h3::H3ClientState};
2use std::{fmt::Debug, sync::Arc, time::Duration};
3use trillium_http::{
4    HeaderName, HeaderValues, Headers, HttpContext, KnownHeaderName, Method, ProtocolSession,
5    ReceivedBodyState, TypeSet, Version::Http1_1,
6};
7use trillium_server_common::{
8    ArcedConnector, ArcedQuicClientConfig, Connector, QuicClientConfig, Transport,
9    url::{Origin, Url},
10};
11
12/// Default maximum idle time for a pooled HTTP/2 connection. Longer than h1 because the
13/// initial h2 handshake (TCP + TLS + ALPN + SETTINGS exchange) is more expensive to
14/// re-establish.
15const DEFAULT_H2_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
16
17/// Default idle threshold above which a pooled HTTP/2 connection is liveness-pinged before
18/// being handed out for a new request. Below this, we trust the connection without probing.
19const DEFAULT_H2_IDLE_PING_THRESHOLD: Duration = Duration::from_secs(10);
20
21/// Default timeout for the liveness PING — if we don't get an ACK within this window, the
22/// connection is treated as dead and a fresh one is established instead.
23const DEFAULT_H2_IDLE_PING_TIMEOUT: Duration = Duration::from_secs(20);
24
25/// A HTTP Client supporting HTTP/1.x and, when configured with a quic implementation, HTTP/3. See
26/// [`Client::new`] and [`Client::new_with_quic`] for construction information.
27#[derive(Clone, Debug, fieldwork::Fieldwork)]
28pub struct Client {
29    config: ArcedConnector,
30    h3: Option<H3ClientState>,
31    pool: Option<Pool<Origin, Box<dyn Transport>>>,
32    h2_pool: Option<Pool<Origin, H2Pooled>>,
33
34    /// Maximum idle time for a pooled HTTP/2 connection. `None` disables expiry.
35    ///
36    /// Defaults to 5 minutes.
37    #[field(get, set, with, without, copy)]
38    h2_idle_timeout: Option<Duration>,
39
40    /// If a pooled HTTP/2 connection has been idle for longer than this, an active PING is
41    /// sent to verify it's still alive before being handed out. `None` disables the probe.
42    ///
43    /// Defaults to 10 seconds.
44    #[field(get, set, with, copy, without)]
45    h2_idle_ping_threshold: Option<Duration>,
46
47    /// Timeout for the liveness PING sent under the [`h2_idle_ping_threshold`] policy.
48    /// Connections whose ACK doesn't arrive within this window are treated as dead.
49    ///
50    /// Defaults to 20 seconds.
51    ///
52    /// [`h2_idle_ping_threshold`]: Self::h2_idle_ping_threshold
53    #[field(get, set, with, copy)]
54    h2_idle_ping_timeout: Duration,
55
56    /// url base for this client
57    #[field(get)]
58    base: Option<Arc<Url>>,
59
60    /// default request headers
61    #[field(get)]
62    default_headers: Arc<Headers>,
63
64    /// optional per-request timeout
65    #[field(get, set, with, copy, without, option_set_some)]
66    timeout: Option<Duration>,
67
68    /// configuration
69    #[field(get, get_mut, set, with, into)]
70    context: Arc<HttpContext>,
71}
72
73macro_rules! method {
74    ($fn_name:ident, $method:ident) => {
75        method!(
76            $fn_name,
77            $method,
78            concat!(
79                // yep, macro-generated doctests
80                "Builds a new client conn with the ",
81                stringify!($fn_name),
82                " http method and the provided url.
83
84```
85use trillium_client::{Client, Method};
86use trillium_testing::client_config;
87
88let client = Client::new(client_config());
89let conn = client.",
90                stringify!($fn_name),
91                "(\"http://localhost:8080/some/route\"); //<-
92
93assert_eq!(conn.method(), Method::",
94                stringify!($method),
95                ");
96assert_eq!(conn.url().to_string(), \"http://localhost:8080/some/route\");
97```
98"
99            )
100        );
101    };
102
103    ($fn_name:ident, $method:ident, $doc_comment:expr_2021) => {
104        #[doc = $doc_comment]
105        pub fn $fn_name(&self, url: impl IntoUrl) -> Conn {
106            self.build_conn(Method::$method, url)
107        }
108    };
109}
110
111pub(crate) fn default_request_headers() -> Headers {
112    Headers::new()
113        .with_inserted_header(KnownHeaderName::UserAgent, USER_AGENT)
114        .with_inserted_header(KnownHeaderName::Accept, "*/*")
115}
116
117impl Client {
118    method!(get, Get);
119
120    method!(post, Post);
121
122    method!(put, Put);
123
124    method!(delete, Delete);
125
126    method!(patch, Patch);
127
128    /// builds a new client from this `Connector`
129    pub fn new(connector: impl Connector) -> Self {
130        Self {
131            config: ArcedConnector::new(connector),
132            h3: None,
133            pool: Some(Pool::default()),
134            h2_pool: Some(Pool::default()),
135            h2_idle_timeout: Some(DEFAULT_H2_IDLE_TIMEOUT),
136            h2_idle_ping_threshold: Some(DEFAULT_H2_IDLE_PING_THRESHOLD),
137            h2_idle_ping_timeout: DEFAULT_H2_IDLE_PING_TIMEOUT,
138            base: None,
139            default_headers: Arc::new(default_request_headers()),
140            timeout: None,
141            context: Default::default(),
142        }
143    }
144
145    /// Build a new client with both a TCP connector and a QUIC connector for HTTP/3 support.
146    ///
147    /// The connector's runtime and UDP socket type are bound to the QUIC connector here,
148    /// before type erasure, so that `trillium-quinn` and the runtime adapter remain
149    /// independent crates that neither depends on the other.
150    ///
151    /// When H3 is configured, the client will track `Alt-Svc` headers in responses and
152    /// automatically use HTTP/3 for subsequent requests to origins that advertise it.
153    /// Requests to origins without a cached alt-svc entry continue to use HTTP/1.1.
154    pub fn new_with_quic<C: Connector, Q: QuicClientConfig<C>>(connector: C, quic: Q) -> Self {
155        // Bind the runtime into the QUIC client config before consuming `connector`.
156        let arced_quic = ArcedQuicClientConfig::new(&connector, quic);
157
158        #[cfg_attr(not(feature = "webtransport"), allow(unused_mut))]
159        let mut context = HttpContext::default();
160        #[cfg(feature = "webtransport")]
161        {
162            // Advertise WebTransport-over-h3 capability on outbound SETTINGS so a server can
163            // open server-initiated WT streams to us once a session is established.
164            // ENABLE_CONNECT_PROTOCOL is included for symmetry with the server side; harmless
165            // when the client never receives extended-CONNECT from the peer.
166            context
167                .config_mut()
168                .set_h3_datagrams_enabled(true)
169                .set_webtransport_enabled(true)
170                .set_extended_connect_enabled(true);
171        }
172
173        Self {
174            config: ArcedConnector::new(connector),
175            h3: Some(H3ClientState::new(arced_quic)),
176            pool: Some(Pool::default()),
177            h2_pool: Some(Pool::default()),
178            h2_idle_timeout: Some(DEFAULT_H2_IDLE_TIMEOUT),
179            h2_idle_ping_threshold: Some(DEFAULT_H2_IDLE_PING_THRESHOLD),
180            h2_idle_ping_timeout: DEFAULT_H2_IDLE_PING_TIMEOUT,
181            base: None,
182            default_headers: Arc::new(default_request_headers()),
183            timeout: None,
184            context: Arc::new(context),
185        }
186    }
187
188    /// chainable method to remove a header from default request headers
189    pub fn without_default_header(mut self, name: impl Into<HeaderName<'static>>) -> Self {
190        self.default_headers_mut().remove(name);
191        self
192    }
193
194    /// chainable method to insert a new default request header, replacing any existing value
195    pub fn with_default_header(
196        mut self,
197        name: impl Into<HeaderName<'static>>,
198        value: impl Into<HeaderValues>,
199    ) -> Self {
200        self.default_headers_mut().insert(name, value);
201        self
202    }
203
204    /// borrow the default headers mutably
205    ///
206    /// calling this will copy-on-write if the default headers are shared with another client clone
207    pub fn default_headers_mut(&mut self) -> &mut Headers {
208        Arc::make_mut(&mut self.default_headers)
209    }
210
211    /// chainable constructor to disable http/1.1 connection reuse.
212    ///
213    /// ```
214    /// use trillium_client::Client;
215    /// use trillium_smol::ClientConfig;
216    ///
217    /// let client = Client::new(ClientConfig::default()).without_keepalive();
218    /// ```
219    pub fn without_keepalive(mut self) -> Self {
220        self.pool = None;
221        self.h2_pool = None;
222        self
223    }
224
225    /// builds a new conn.
226    ///
227    /// if the client has pooling enabled and there is
228    /// an available connection to the dns-resolved socket (ip and port),
229    /// the new conn will reuse that when it is sent.
230    ///
231    /// ```
232    /// use trillium_client::{Client, Method};
233    /// use trillium_smol::ClientConfig;
234    /// let client = Client::new(ClientConfig::default());
235    ///
236    /// let conn = client.build_conn("get", "http://trillium.rs"); //<-
237    ///
238    /// assert_eq!(conn.method(), Method::Get);
239    /// assert_eq!(conn.url().host_str().unwrap(), "trillium.rs");
240    /// ```
241    pub fn build_conn<M>(&self, method: M, url: impl IntoUrl) -> Conn
242    where
243        M: TryInto<Method>,
244        <M as TryInto<Method>>::Error: Debug,
245    {
246        let method = method.try_into().unwrap();
247        let (url, request_target) = if let Some(base) = &self.base
248            && let Some(request_target) = url.request_target(method)
249        {
250            ((**base).clone(), Some(request_target))
251        } else {
252            (self.build_url(url).unwrap(), None)
253        };
254
255        Conn {
256            url,
257            method,
258            request_headers: Headers::clone(&self.default_headers),
259            response_headers: Headers::new(),
260            transport: None,
261            status: None,
262            request_body: None,
263            pool: self.pool.clone(),
264            h2_pool: self.h2_pool.clone(),
265            h2_idle_timeout: self.h2_idle_timeout,
266            h2_idle_ping_threshold: self.h2_idle_ping_threshold,
267            h2_idle_ping_timeout: self.h2_idle_ping_timeout,
268            h3_client_state: self.h3.clone(),
269            protocol_session: ProtocolSession::Http1,
270            #[cfg(feature = "webtransport")]
271            wt_pool_entry: None,
272            buffer: Vec::with_capacity(128).into(),
273            response_body_state: ReceivedBodyState::Start,
274            config: self.config.clone(),
275            headers_finalized: false,
276            timeout: self.timeout,
277            http_version: Http1_1,
278            max_head_length: 8 * 1024,
279            state: TypeSet::new(),
280            context: self.context.clone(),
281            authority: None,
282            scheme: None,
283            path: None,
284            request_target,
285            protocol: None,
286            request_trailers: None,
287            response_trailers: None,
288        }
289    }
290
291    /// borrow the connector for this client
292    pub fn connector(&self) -> &ArcedConnector {
293        &self.config
294    }
295
296    /// The pool implementation currently accumulates a small memory
297    /// footprint for each new host. If your application is reusing a pool
298    /// against a large number of unique hosts, call this method
299    /// intermittently.
300    pub fn clean_up_pool(&self) {
301        if let Some(pool) = &self.pool {
302            pool.cleanup();
303        }
304        if let Some(h2_pool) = &self.h2_pool {
305            h2_pool.cleanup();
306        }
307    }
308
309    /// chainable method to set the base for this client
310    pub fn with_base(mut self, base: impl IntoUrl) -> Self {
311        self.set_base(base).unwrap();
312        self
313    }
314
315    /// attempt to build a url from this IntoUrl and the [`Client::base`], if set
316    pub fn build_url(&self, url: impl IntoUrl) -> crate::Result<Url> {
317        url.into_url(self.base())
318    }
319
320    /// set the base for this client
321    pub fn set_base(&mut self, base: impl IntoUrl) -> crate::Result<()> {
322        let mut base = base.into_url(None)?;
323
324        if !base.path().ends_with('/') {
325            log::warn!("appending a trailing / to {base}");
326            base.set_path(&format!("{}/", base.path()));
327        }
328
329        self.base = Some(Arc::new(base));
330        Ok(())
331    }
332
333    /// Mutate the url base for this client.
334    ///
335    /// This has "clone-on-write" semantics if there are other clones of this client. If there are
336    /// other clones of this client, they will not be updated.
337    pub fn base_mut(&mut self) -> Option<&mut Url> {
338        let base = self.base.as_mut()?;
339        Some(Arc::make_mut(base))
340    }
341}
342
343impl<T: Connector> From<T> for Client {
344    fn from(connector: T) -> Self {
345        Self::new(connector)
346    }
347}