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}