Skip to main content

trillium_client/
conn.rs

1use crate::{
2    Client, ResponseBody,
3    response_body::{CleanupContext, OverrideBody},
4    util::encoding,
5};
6use std::{borrow::Cow, mem, net::SocketAddr, sync::Arc, time::Duration};
7use trillium_http::{
8    Body, Buffer, Error, HeaderName, HeaderValues, Headers, HttpContext, Method, ProtocolSession,
9    ReceivedBody, ReceivedBodyState, Status, TypeSet, Version,
10};
11use trillium_server_common::{Transport, url::Url};
12
13mod h1;
14#[cfg(test)]
15mod h1_tests;
16mod h2;
17mod h3;
18mod request_body_buffer;
19mod shared;
20mod unexpected_status_error;
21
22pub(crate) use h2::H2Pooled;
23#[cfg(any(feature = "serde_json", feature = "sonic-rs"))]
24pub use shared::ClientSerdeError;
25pub use unexpected_status_error::UnexpectedStatusError;
26
27/// a client connection, representing both an outbound http request and a
28/// http response
29#[must_use]
30#[derive(fieldwork::Fieldwork)]
31pub struct Conn {
32    pub(crate) protocol_session: ProtocolSession,
33    /// QUIC-connection WebTransport dispatcher slot (lazy-init) and the QUIC connection
34    /// itself, retained on extended-CONNECT-with-`:protocol = webtransport` requests so
35    /// `into_webtransport` can install the router and hand the QUIC connection to the
36    /// returned [`WebTransportConnection`][trillium_webtransport::WebTransportConnection].
37    #[cfg(feature = "webtransport")]
38    pub(crate) wt_pool_entry: Option<crate::h3::H3PoolEntry>,
39    pub(crate) buffer: Buffer,
40    pub(crate) response_body_state: ReceivedBodyState,
41    pub(crate) headers_finalized: bool,
42    pub(crate) state: TypeSet,
43    pub(crate) context: Arc<HttpContext>,
44
45    /// the transport for this conn
46    ///
47    /// This should only be used to call your own custom methods on the transport that do not read
48    /// or write any data. Calling any method that reads from or writes to the transport will
49    /// disrupt the HTTP protocol.
50    #[field(get, get_mut)]
51    pub(crate) transport: Option<Box<dyn Transport>>,
52
53    /// the url for this conn.
54    ///
55    /// ```
56    /// use trillium_client::{Client, Method};
57    /// use trillium_testing::client_config;
58    ///
59    /// let client = Client::from(client_config());
60    ///
61    /// let conn = client.get("http://localhost:9080");
62    ///
63    /// let url = conn.url(); //<-
64    ///
65    /// assert_eq!(url.host_str().unwrap(), "localhost");
66    /// ```
67    #[field(get, set, get_mut)]
68    pub(crate) url: Url,
69
70    /// the method for this conn.
71    ///
72    /// ```
73    /// use trillium_client::{Client, Method};
74    /// use trillium_testing::client_config;
75    ///
76    /// let client = Client::from(client_config());
77    /// let conn = client.get("http://localhost:9080");
78    ///
79    /// let method = conn.method(); //<-
80    ///
81    /// assert_eq!(method, Method::Get);
82    /// ```
83    #[field(get, set, copy)]
84    pub(crate) method: Method,
85
86    /// the request headers
87    #[field(get, get_mut)]
88    pub(crate) request_headers: Headers,
89
90    #[field(get)]
91    /// the response headers
92    pub(crate) response_headers: Headers,
93
94    /// the status code for this conn.
95    ///
96    /// If the conn has not yet been sent, this will be None.
97    ///
98    /// ```
99    /// use trillium_client::{Client, Status};
100    /// use trillium_testing::{client_config, with_server};
101    ///
102    /// async fn handler(conn: trillium::Conn) -> trillium::Conn {
103    ///     conn.with_status(418)
104    /// }
105    ///
106    /// with_server(handler, |url| async move {
107    ///     let client = Client::new(client_config());
108    ///     let conn = client.get(url).await?;
109    ///     assert_eq!(Status::ImATeapot, conn.status().unwrap());
110    ///     Ok(())
111    /// });
112    /// ```
113    #[field(get, copy)]
114    pub(crate) status: Option<Status>,
115
116    /// the request body
117    ///
118    /// ```
119    /// env_logger::init();
120    /// use trillium_client::Client;
121    /// use trillium_testing::{client_config, with_server};
122    ///
123    /// let handler = |mut conn: trillium::Conn| async move {
124    ///     let body = conn.request_body_string().await.unwrap();
125    ///     conn.ok(format!("request body was: {}", body))
126    /// };
127    ///
128    /// with_server(handler, |url| async move {
129    ///     let client = Client::from(client_config());
130    ///     let mut conn = client
131    ///         .post(url)
132    ///         .with_body("body") //<-
133    ///         .await?;
134    ///
135    ///     assert_eq!(
136    ///         conn.response_body().read_string().await?,
137    ///         "request body was: body"
138    ///     );
139    ///     Ok(())
140    /// });
141    /// ```
142    #[field(get, with = with_body, argument = body, set, into, take, option_set_some)]
143    pub(crate) request_body: Option<Body>,
144
145    /// Whether the request body was fully buffered before sending (see
146    /// [`request_body_buffer`](crate::conn::request_body_buffer)). When true, the h1 send path
147    /// skips the `Expect: 100-continue` handshake — a buffered body is cheap to send in one shot.
148    pub(crate) request_body_fully_buffered: bool,
149
150    /// the timeout for this conn
151    ///
152    /// this can also be set on the client with [`Client::set_timeout`](crate::Client::set_timeout)
153    /// and [`Client::with_timeout`](crate::Client::with_timeout)
154    #[field(with, set, get, get_mut, take, copy, option_set_some)]
155    pub(crate) timeout: Option<Duration>,
156
157    /// whether this conn is halted.
158    ///
159    /// When set to `true` before execution, the network round-trip is skipped — the conn is
160    /// returned to the caller with whatever response state has been populated synthetically
161    /// (status, headers, body). Used by client middleware to short-circuit on cache hits,
162    /// mocked responses, or open circuit-breakers. Cleared on egress so the user's conn handle
163    /// never observes residual halt state after the awaited conn returns.
164    ///
165    /// Driven via [`ConnExt`](crate::ConnExt) — `halt` / `set_halted` / `is_halted`.
166    pub(crate) halted: bool,
167
168    /// transport-level error from the round-trip, if any.
169    ///
170    /// When the network call fails (connect refused, TLS handshake error, malformed HTTP frame,
171    /// timeout, etc.) the framework stashes the error here and runs the handler chain's
172    /// [`after_response`](crate::ClientHandler::after_response) anyway. A handler that recovers
173    /// (stale-if-error cache, retry-with-fallback) calls
174    /// [`ConnExt::take_error`](crate::ConnExt::take_error) to clear the error
175    /// and populates response state synthetically; if the error is still present after all
176    /// handlers finish, it propagates as `Err` from the awaited conn.
177    pub(crate) error: Option<Error>,
178
179    /// An override response body installed by middleware via
180    /// [`ConnExt::set_response_body`](crate::ConnExt::set_response_body) or
181    /// [`ConnExt::with_response_body`](crate::ConnExt::with_response_body). When
182    /// set, [`Conn::response_body`] returns a [`ResponseBody`] backed by this body instead of
183    /// the transport.
184    pub(crate) body_override: Option<Body>,
185
186    /// the http version *hint* for this conn
187    ///
188    /// Pre-execution this is the prior-knowledge hint, not the version that will necessarily be
189    /// on the wire. `None` means "no hint, use auto-discovery" (Alt-Svc h3, ALPN/pooled h2);
190    /// `Some(version)` names the protocol to try first. Post-execution this is `Some(version)`
191    /// reflecting the version the request was actually sent over.
192    ///
193    /// The public [`http_version`](Conn::http_version) accessor resolves `None` to
194    /// [`Version::Http1_1`]. See the crate-level [Protocol selection][crate#protocol-selection]
195    /// documentation.
196    #[field(set, with, option_set_some)]
197    pub(crate) http_version: Option<Version>,
198
199    /// Whether a request that cannot be carried by the protocol it was matched to fails
200    /// rather than being retried on an earlier protocol.
201    ///
202    /// Off by default: a websocket handshake that lands on an h2 or h3 connection whose peer
203    /// doesn't support extended CONNECT is retried as an HTTP/1.1 upgrade on a new connection.
204    /// When on, that peer yields an error instead. A version hint sets the protocol to try
205    /// first; this flag decides what happens when that protocol can't carry the request.
206    ///
207    /// This can also be set for every conn on the client with
208    /// [`Client::set_strict_http_version`](crate::Client::set_strict_http_version).
209    #[field(get, set, with, without, copy)]
210    pub(crate) strict_http_version: bool,
211
212    /// the :authority pseudo-header, populated during h2 or h3 header finalization
213    #[field(get)]
214    pub(crate) authority: Option<Cow<'static, str>>,
215    /// the :scheme pseudo-header, populated during h2 or h3 header finalization
216
217    #[field(get)]
218    pub(crate) scheme: Option<Cow<'static, str>>,
219
220    /// the :path pseudo-header, populated during h2 or h3 header finalization
221    #[field(get)]
222    pub(crate) path: Option<Cow<'static, str>>,
223
224    /// an explicit request target override, used only for `OPTIONS *` and `CONNECT host:port`
225    ///
226    /// When set and the method is OPTIONS or CONNECT, this value is used as the HTTP request
227    /// target instead of deriving it from the url. For all other methods, this field is ignored.
228    #[field(with, set, get, option_set_some, into)]
229    pub(crate) request_target: Option<Cow<'static, str>>,
230
231    /// The protocol this request switches the connection to, if any: sent as the `Upgrade`
232    /// header over HTTP/1.1 and as the `:protocol` pseudo-header of an extended CONNECT over
233    /// HTTP/2 and HTTP/3. Either way the stream is left open as a bidirectional byte channel
234    /// after the response head.
235    #[field(get)]
236    pub(crate) protocol: Option<Cow<'static, str>>,
237
238    /// trailers sent with the request body, populated after the body has been fully sent.
239    ///
240    /// Only present when the request body was constructed with [`Body::new_with_trailers`] and
241    /// the body has been fully sent.
242    #[field(get)]
243    pub(crate) request_trailers: Option<Headers>,
244
245    /// trailers received with the response body, populated after the response body has been fully
246    /// read.
247    #[field(get)]
248    pub(crate) response_trailers: Option<Headers>,
249
250    /// the [`Client`] that built this conn.
251    #[field(get)]
252    pub(crate) client: Client,
253
254    /// A queued follow-up conn installed by middleware via
255    /// [`ConnExt::set_followup`](crate::ConnExt::set_followup).
256    ///
257    /// When `Some` after the handler chain's `after_response` has fully unwound, the
258    /// [`IntoFuture`][std::future::IntoFuture] loop picks it up: the current conn's response
259    /// body is recycled, then the follow-up is swapped in and runs another full
260    /// `(run → network → after_response)` cycle. Used by re-issuing handlers
261    /// (`FollowRedirects`, retry, auth-refresh) instead of recursing into a nested `.await`.
262    pub(crate) followup: Option<Box<Conn>>,
263
264    /// Whether this conn is armed for an upgrade. When set, the protocol drivers
265    /// transmit only request headers and leave the outbound direction open. Armed via
266    /// [`ConnExt::upgrade`](crate::ConnExt::upgrade).
267    pub(crate) upgrade: bool,
268}
269
270/// default http user-agent header
271pub const USER_AGENT: &str = concat!("trillium-client/", env!("CARGO_PKG_VERSION"));
272
273impl Conn {
274    /// the http version for this conn
275    ///
276    /// Pre-execution this resolves the version *hint* — the default (no hint) reports
277    /// [`Version::Http1_1`], which means "use auto-discovery," not "force HTTP/1.1." An
278    /// explicit version set via [`with_http_version`](Conn::with_http_version) is the protocol
279    /// to try first. Post-execution this reflects the version the request was actually sent
280    /// over.
281    ///
282    /// See the crate-level [Protocol selection][crate#protocol-selection] documentation.
283    #[must_use]
284    pub fn http_version(&self) -> Version {
285        self.http_version.unwrap_or(Version::Http1_1)
286    }
287
288    /// chainable setter for [`inserting`](Headers::insert) a request header
289    ///
290    /// ```
291    /// use trillium_client::Client;
292    /// use trillium_testing::{client_config, with_server};
293    ///
294    /// let handler = |conn: trillium::Conn| async move {
295    ///     let header = conn
296    ///         .request_headers()
297    ///         .get_str("some-request-header")
298    ///         .unwrap_or_default();
299    ///     let response = format!("some-request-header was {}", header);
300    ///     conn.ok(response)
301    /// };
302    ///
303    /// with_server(handler, |url| async move {
304    ///     let client = Client::new(client_config());
305    ///     let mut conn = client
306    ///         .get(url)
307    ///         .with_request_header("some-request-header", "header-value") // <--
308    ///         .await?;
309    ///     assert_eq!(
310    ///         conn.response_body().read_string().await?,
311    ///         "some-request-header was header-value"
312    ///     );
313    ///     Ok(())
314    /// })
315    /// ```
316    pub fn with_request_header(
317        mut self,
318        name: impl Into<HeaderName<'static>>,
319        value: impl Into<HeaderValues>,
320    ) -> Self {
321        self.request_headers.insert(name, value);
322        self
323    }
324
325    /// chainable setter for `extending` request headers
326    ///
327    /// ```
328    /// use trillium_client::Client;
329    /// use trillium_testing::{client_config, with_server};
330    ///
331    /// let handler = |conn: trillium::Conn| async move {
332    ///     let header = conn
333    ///         .request_headers()
334    ///         .get_str("some-request-header")
335    ///         .unwrap_or_default();
336    ///     let response = format!("some-request-header was {}", header);
337    ///     conn.ok(response)
338    /// };
339    ///
340    /// with_server(handler, move |url| async move {
341    ///     let client = Client::new(client_config());
342    ///     let mut conn = client
343    ///         .get(url)
344    ///         .with_request_headers([
345    ///             ("some-request-header", "header-value"),
346    ///             ("some-other-req-header", "other-header-value"),
347    ///         ])
348    ///         .await?;
349    ///
350    ///     assert_eq!(
351    ///         conn.response_body().read_string().await?,
352    ///         "some-request-header was header-value"
353    ///     );
354    ///     Ok(())
355    /// })
356    /// ```
357    pub fn with_request_headers<HN, HV, I>(mut self, headers: I) -> Self
358    where
359        I: IntoIterator<Item = (HN, HV)> + Send,
360        HN: Into<HeaderName<'static>>,
361        HV: Into<HeaderValues>,
362    {
363        self.request_headers.extend(headers);
364        self
365    }
366
367    /// Chainable method to remove a request header if present
368    pub fn without_request_header(mut self, name: impl Into<HeaderName<'static>>) -> Self {
369        self.request_headers.remove(name);
370        self
371    }
372
373    /// chainable setter for json body. this requires the `serde_json` crate feature to be enabled.
374    #[cfg(feature = "serde_json")]
375    pub fn with_json_body(self, body: &impl serde::Serialize) -> serde_json::Result<Self> {
376        use trillium_http::KnownHeaderName;
377
378        Ok(self
379            .with_body(serde_json::to_string(body)?)
380            .with_request_header(KnownHeaderName::ContentType, "application/json"))
381    }
382
383    /// chainable setter for json body. this requires the `sonic-rs` crate feature to be enabled.
384    #[cfg(feature = "sonic-rs")]
385    pub fn with_json_body(self, body: &impl serde::Serialize) -> sonic_rs::Result<Self> {
386        use trillium_http::KnownHeaderName;
387
388        Ok(self
389            .with_body(sonic_rs::to_string(body)?)
390            .with_request_header(KnownHeaderName::ContentType, "application/json"))
391    }
392
393    /// returns a [`ResponseBody`](crate::ResponseBody) that borrows the connection inside this
394    /// conn.
395    /// ```
396    /// use trillium_client::Client;
397    /// use trillium_testing::{client_config, with_server};
398    ///
399    /// let handler = |mut conn: trillium::Conn| async move { conn.ok("hello from trillium") };
400    ///
401    /// with_server(handler, |url| async move {
402    ///     let client = Client::from(client_config());
403    ///     let mut conn = client.get(url).await?;
404    ///
405    ///     let response_body = conn.response_body(); //<-
406    ///
407    ///     assert_eq!(19, response_body.content_length().unwrap());
408    ///     let string = response_body.read_string().await?;
409    ///     assert_eq!("hello from trillium", string);
410    ///     Ok(())
411    /// });
412    /// ```
413    #[allow(clippy::needless_borrow, clippy::needless_borrows_for_generic_args)]
414    pub fn response_body(&mut self) -> ResponseBody<'_> {
415        let content_length = self.response_content_length();
416        let encoding = encoding(&self.response_headers);
417        if let Some(body) = self.body_override.as_mut() {
418            OverrideBody::new(body, encoding, self.context.config()).into()
419        } else {
420            ReceivedBody::new(
421                content_length,
422                &mut self.buffer,
423                self.transport.as_mut().unwrap(),
424                &mut self.response_body_state,
425                None,
426                encoding,
427            )
428            .with_trailers(&mut self.response_trailers)
429            .with_protocol_session(self.protocol_session.clone())
430            .into()
431        }
432    }
433
434    /// Attempt to deserialize the response body. Note that this consumes the body content.
435    #[cfg(feature = "serde_json")]
436    pub async fn response_json<T>(&mut self) -> Result<T, ClientSerdeError>
437    where
438        T: serde::de::DeserializeOwned,
439    {
440        let body = self.response_body().read_string().await?;
441        Ok(serde_json::from_str(&body)?)
442    }
443
444    /// Attempt to deserialize the response body. Note that this consumes the body content.
445    #[cfg(feature = "sonic-rs")]
446    pub async fn response_json<T>(&mut self) -> Result<T, ClientSerdeError>
447    where
448        T: serde::de::DeserializeOwned,
449    {
450        let body = self.response_body().read_string().await?;
451        Ok(sonic_rs::from_str(&body)?)
452    }
453
454    /// Returns the conn or an [`UnexpectedStatusError`] that contains the conn
455    ///
456    /// ```
457    /// use trillium_client::{Client, Status};
458    /// use trillium_testing::{client_config, with_server};
459    ///
460    /// with_server(Status::NotFound, |url| async move {
461    ///     let client = Client::new(client_config());
462    ///     assert_eq!(
463    ///         client.get(url).await?.success().unwrap_err().to_string(),
464    ///         "expected a success (2xx) status code, but got 404 Not Found"
465    ///     );
466    ///     Ok(())
467    /// });
468    ///
469    /// with_server(Status::Ok, |url| async move {
470    ///     let client = Client::new(client_config());
471    ///     assert!(client.get(url).await?.success().is_ok());
472    ///     Ok(())
473    /// });
474    /// ```
475    pub fn success(self) -> Result<Self, UnexpectedStatusError> {
476        match self.status() {
477            Some(status) if status.is_success() => Ok(self),
478            _ => Err(self.into()),
479        }
480    }
481
482    /// Detach the response body as an owned, `'static` value.
483    ///
484    /// Returns `None` if there is no body to take — neither an override has been installed nor
485    /// a transport-backed body is available. Subsequent calls return `None`. Callers who want
486    /// to wrap-and-replace the body (e.g. tee through a cache) compose this with
487    /// [`ConnExt::set_response_body`][crate::ConnExt::set_response_body]; the conn's
488    /// body slot is empty between the two calls.
489    ///
490    /// For a transport-backed body, this moves the transport into the returned
491    /// `ResponseBody<'static>`. Drop on that value drains-and-pools (keepalive) or closes
492    /// (otherwise) the transport via a spawned task; [`ResponseBody::recycle`] is the
493    /// `await`-able variant. For an override body, the inner [`Body`] is moved out and any
494    /// leftover transport on the conn is recycled immediately.
495    #[must_use]
496    pub fn take_response_body(&mut self) -> Option<ResponseBody<'static>> {
497        let encoding = encoding(&self.response_headers);
498        if let Some(body) = self.body_override.take() {
499            return Some(OverrideBody::new(body, encoding, self.context.config()).into());
500        }
501
502        let cleanup = self.build_cleanup_context();
503        let received = self.take_received_body(false)?;
504        Some(ResponseBody::received_owned(received, cleanup))
505    }
506
507    /// Build a [`CleanupContext`] capturing the runtime and (if keepalive + pool configured)
508    /// the pool + origin to insert into. Single source of truth for "what should happen to
509    /// this conn's transport when its body is released" — both the on_completion callback
510    /// wired into the body and the [`ResponseBody::recycle`] / `Drop` paths consume clones
511    /// of this same context, so the user-driven and Drop-driven release paths agree.
512    fn build_cleanup_context(&self) -> CleanupContext {
513        // Only pool a transport whose response head we actually received (`status.is_some()`): a
514        // conn abandoned before the response — a timeout or transport error mid-request — has an
515        // empty `response_headers`, which `is_keep_alive` would read as persistent and recycle a
516        // half-spent connection into the pool, poisoning the next request that reuses it.
517        let h1_pool_origin = if self.status.is_some()
518            && self.is_keep_alive()
519            && let Some(pool) = self.client.pool().cloned()
520        {
521            Some((pool, self.url.origin()))
522        } else {
523            None
524        };
525
526        CleanupContext {
527            runtime: self.client.connector().runtime(),
528            h1_pool_origin,
529            h1_idle_timeout: self.client.h1_idle_timeout(),
530        }
531    }
532
533    /// Detach the transport-backed receive side of this conn as an owned `ReceivedBody`.
534    ///
535    /// Returns `None` when no transport is attached.
536    ///
537    /// `cleanup: true` wires a spawn-on-End callback inside the body for callers that hand
538    /// the body off without awaiting it (`From<Conn> for Body`). `cleanup: false` is for
539    /// callers that drive the body to End themselves and release the transport inline in
540    /// their own poll loop — `take_response_body` does this so callers get a "transport is
541    /// settled when read_to_end returns Ok(0)" guarantee instead of racing a spawned task.
542    pub(crate) fn take_received_body(
543        &mut self,
544        cleanup: bool,
545    ) -> Option<ReceivedBody<'static, Box<dyn Transport>>> {
546        let _ = self.finalize_headers();
547        let transport = self.transport.take()?;
548
549        let on_completion = cleanup.then(|| {
550            let cleanup = self.build_cleanup_context();
551            Box::new(move |transport| cleanup.handoff(transport))
552                as Box<dyn FnOnce(Box<dyn Transport>) + Send + Sync + 'static>
553        });
554
555        Some(
556            ReceivedBody::new(
557                self.response_content_length(),
558                mem::take(&mut self.buffer),
559                transport,
560                self.response_body_state,
561                on_completion,
562                encoding(&self.response_headers),
563            )
564            .with_protocol_session(self.protocol_session.clone()),
565        )
566    }
567
568    /// Returns this conn to the connection pool if it is keepalive, and
569    /// closes it otherwise. This will happen asynchronously as a spawned
570    /// task when the conn is dropped, but calling it explicitly allows
571    /// you to block on it and control where it happens.
572    pub async fn recycle(mut self) {
573        if let Some(rb) = self.take_response_body() {
574            rb.recycle().await;
575        }
576    }
577
578    /// attempts to retrieve the connected peer address
579    pub fn peer_addr(&self) -> Option<SocketAddr> {
580        self.transport
581            .as_ref()
582            .and_then(|t| t.peer_addr().ok().flatten())
583    }
584
585    /// add state to the client conn and return self
586    pub fn with_state<T: Send + Sync + 'static>(mut self, state: T) -> Self {
587        self.insert_state(state);
588        self
589    }
590
591    /// add state to the client conn, returning any previously set state of this type
592    pub fn insert_state<T: Send + Sync + 'static>(&mut self, state: T) -> Option<T> {
593        self.state.insert(state)
594    }
595
596    /// borrow state
597    pub fn state<T: Send + Sync + 'static>(&self) -> Option<&T> {
598        self.state.get()
599    }
600
601    /// borrow state mutably
602    pub fn state_mut<T: Send + Sync + 'static>(&mut self) -> Option<&mut T> {
603        self.state.get_mut()
604    }
605
606    /// take state
607    pub fn take_state<T: Send + Sync + 'static>(&mut self) -> Option<T> {
608        self.state.take()
609    }
610}