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