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