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 /// any `Some(version)` pins the protocol and suppresses auto-discovery. Post-execution this
191 /// is `Some(version)` 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 for the full hint → behavior table.
196 #[field(set, with, option_set_some)]
197 pub(crate) http_version: Option<Version>,
198
199 /// the :authority pseudo-header, populated during h2 or h3 header finalization
200 #[field(get)]
201 pub(crate) authority: Option<Cow<'static, str>>,
202 /// the :scheme pseudo-header, populated during h2 or h3 header finalization
203
204 #[field(get)]
205 pub(crate) scheme: Option<Cow<'static, str>>,
206
207 /// the :path pseudo-header, populated during h2 or h3 header finalization
208 #[field(get)]
209 pub(crate) path: Option<Cow<'static, str>>,
210
211 /// an explicit request target override, used only for `OPTIONS *` and `CONNECT host:port`
212 ///
213 /// When set and the method is OPTIONS or CONNECT, this value is used as the HTTP request
214 /// target instead of deriving it from the url. For all other methods, this field is ignored.
215 #[field(with, set, get, option_set_some, into)]
216 pub(crate) request_target: Option<Cow<'static, str>>,
217
218 /// the `:protocol` pseudo-header for an extended-CONNECT bootstrap (RFC 8441 over h2,
219 /// RFC 9220 over h3). Triggers the h2/h3 exec paths to send HEADERS without `END_STREAM`
220 /// and leave the stream open as a bidirectional byte channel.
221 ///
222 /// Only meaningful when method is `CONNECT` and [`http_version`][Self::http_version] is
223 /// `Http2` or `Http3`. h1 and prior-version requests ignore this field.
224 #[field(get)]
225 pub(crate) protocol: Option<Cow<'static, str>>,
226
227 /// trailers sent with the request body, populated after the body has been fully sent.
228 ///
229 /// Only present when the request body was constructed with [`Body::new_with_trailers`] and
230 /// the body has been fully sent.
231 #[field(get)]
232 pub(crate) request_trailers: Option<Headers>,
233
234 /// trailers received with the response body, populated after the response body has been fully
235 /// read.
236 #[field(get)]
237 pub(crate) response_trailers: Option<Headers>,
238
239 /// the [`Client`] that built this conn.
240 #[field(get)]
241 pub(crate) client: Client,
242
243 /// A queued follow-up conn installed by middleware via
244 /// [`ConnExt::set_followup`](crate::ConnExt::set_followup).
245 ///
246 /// When `Some` after the handler chain's `after_response` has fully unwound, the
247 /// [`IntoFuture`][std::future::IntoFuture] loop picks it up: the current conn's response
248 /// body is recycled, then the follow-up is swapped in and runs another full
249 /// `(run → network → after_response)` cycle. Used by re-issuing handlers
250 /// (`FollowRedirects`, retry, auth-refresh) instead of recursing into a nested `.await`.
251 pub(crate) followup: Option<Box<Conn>>,
252
253 /// Whether this conn is armed for an upgrade. When set, the protocol drivers
254 /// transmit only request headers and leave the outbound direction open. Armed via
255 /// [`ConnExt::upgrade`](crate::ConnExt::upgrade).
256 pub(crate) upgrade: bool,
257}
258
259/// default http user-agent header
260pub const USER_AGENT: &str = concat!("trillium-client/", env!("CARGO_PKG_VERSION"));
261
262impl Conn {
263 /// the http version for this conn
264 ///
265 /// Pre-execution this resolves the version *hint* — the default (no hint) reports
266 /// [`Version::Http1_1`], which means "use auto-discovery," not "force HTTP/1.1." Setting any
267 /// explicit version via [`with_http_version`](Conn::with_http_version) pins the protocol and
268 /// suppresses auto-discovery. Post-execution this reflects the version the request was actually
269 /// sent over.
270 ///
271 /// See the crate-level [Protocol selection][crate#protocol-selection] documentation for the
272 /// full hint → behavior table.
273 #[must_use]
274 pub fn http_version(&self) -> Version {
275 self.http_version.unwrap_or(Version::Http1_1)
276 }
277
278 /// chainable setter for [`inserting`](Headers::insert) a request header
279 ///
280 /// ```
281 /// use trillium_client::Client;
282 /// use trillium_testing::{client_config, with_server};
283 ///
284 /// let handler = |conn: trillium::Conn| async move {
285 /// let header = conn
286 /// .request_headers()
287 /// .get_str("some-request-header")
288 /// .unwrap_or_default();
289 /// let response = format!("some-request-header was {}", header);
290 /// conn.ok(response)
291 /// };
292 ///
293 /// with_server(handler, |url| async move {
294 /// let client = Client::new(client_config());
295 /// let mut conn = client
296 /// .get(url)
297 /// .with_request_header("some-request-header", "header-value") // <--
298 /// .await?;
299 /// assert_eq!(
300 /// conn.response_body().read_string().await?,
301 /// "some-request-header was header-value"
302 /// );
303 /// Ok(())
304 /// })
305 /// ```
306 pub fn with_request_header(
307 mut self,
308 name: impl Into<HeaderName<'static>>,
309 value: impl Into<HeaderValues>,
310 ) -> Self {
311 self.request_headers.insert(name, value);
312 self
313 }
314
315 /// chainable setter for `extending` request headers
316 ///
317 /// ```
318 /// use trillium_client::Client;
319 /// use trillium_testing::{client_config, with_server};
320 ///
321 /// let handler = |conn: trillium::Conn| async move {
322 /// let header = conn
323 /// .request_headers()
324 /// .get_str("some-request-header")
325 /// .unwrap_or_default();
326 /// let response = format!("some-request-header was {}", header);
327 /// conn.ok(response)
328 /// };
329 ///
330 /// with_server(handler, move |url| async move {
331 /// let client = Client::new(client_config());
332 /// let mut conn = client
333 /// .get(url)
334 /// .with_request_headers([
335 /// ("some-request-header", "header-value"),
336 /// ("some-other-req-header", "other-header-value"),
337 /// ])
338 /// .await?;
339 ///
340 /// assert_eq!(
341 /// conn.response_body().read_string().await?,
342 /// "some-request-header was header-value"
343 /// );
344 /// Ok(())
345 /// })
346 /// ```
347 pub fn with_request_headers<HN, HV, I>(mut self, headers: I) -> Self
348 where
349 I: IntoIterator<Item = (HN, HV)> + Send,
350 HN: Into<HeaderName<'static>>,
351 HV: Into<HeaderValues>,
352 {
353 self.request_headers.extend(headers);
354 self
355 }
356
357 /// Chainable method to remove a request header if present
358 pub fn without_request_header(mut self, name: impl Into<HeaderName<'static>>) -> Self {
359 self.request_headers.remove(name);
360 self
361 }
362
363 /// chainable setter for json body. this requires the `serde_json` crate feature to be enabled.
364 #[cfg(feature = "serde_json")]
365 pub fn with_json_body(self, body: &impl serde::Serialize) -> serde_json::Result<Self> {
366 use trillium_http::KnownHeaderName;
367
368 Ok(self
369 .with_body(serde_json::to_string(body)?)
370 .with_request_header(KnownHeaderName::ContentType, "application/json"))
371 }
372
373 /// chainable setter for json body. this requires the `sonic-rs` crate feature to be enabled.
374 #[cfg(feature = "sonic-rs")]
375 pub fn with_json_body(self, body: &impl serde::Serialize) -> sonic_rs::Result<Self> {
376 use trillium_http::KnownHeaderName;
377
378 Ok(self
379 .with_body(sonic_rs::to_string(body)?)
380 .with_request_header(KnownHeaderName::ContentType, "application/json"))
381 }
382
383 /// returns a [`ResponseBody`](crate::ResponseBody) that borrows the connection inside this
384 /// conn.
385 /// ```
386 /// use trillium_client::Client;
387 /// use trillium_testing::{client_config, with_server};
388 ///
389 /// let handler = |mut conn: trillium::Conn| async move { conn.ok("hello from trillium") };
390 ///
391 /// with_server(handler, |url| async move {
392 /// let client = Client::from(client_config());
393 /// let mut conn = client.get(url).await?;
394 ///
395 /// let response_body = conn.response_body(); //<-
396 ///
397 /// assert_eq!(19, response_body.content_length().unwrap());
398 /// let string = response_body.read_string().await?;
399 /// assert_eq!("hello from trillium", string);
400 /// Ok(())
401 /// });
402 /// ```
403 #[allow(clippy::needless_borrow, clippy::needless_borrows_for_generic_args)]
404 pub fn response_body(&mut self) -> ResponseBody<'_> {
405 let content_length = self.response_content_length();
406 let encoding = encoding(&self.response_headers);
407 if let Some(body) = self.body_override.as_mut() {
408 OverrideBody::new(body, encoding, self.context.config()).into()
409 } else {
410 ReceivedBody::new(
411 content_length,
412 &mut self.buffer,
413 self.transport.as_mut().unwrap(),
414 &mut self.response_body_state,
415 None,
416 encoding,
417 )
418 .with_trailers(&mut self.response_trailers)
419 .with_protocol_session(self.protocol_session.clone())
420 .into()
421 }
422 }
423
424 /// Attempt to deserialize the response body. Note that this consumes the body content.
425 #[cfg(feature = "serde_json")]
426 pub async fn response_json<T>(&mut self) -> Result<T, ClientSerdeError>
427 where
428 T: serde::de::DeserializeOwned,
429 {
430 let body = self.response_body().read_string().await?;
431 Ok(serde_json::from_str(&body)?)
432 }
433
434 /// Attempt to deserialize the response body. Note that this consumes the body content.
435 #[cfg(feature = "sonic-rs")]
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(sonic_rs::from_str(&body)?)
442 }
443
444 /// Returns the conn or an [`UnexpectedStatusError`] that contains the conn
445 ///
446 /// ```
447 /// use trillium_client::{Client, Status};
448 /// use trillium_testing::{client_config, with_server};
449 ///
450 /// with_server(Status::NotFound, |url| async move {
451 /// let client = Client::new(client_config());
452 /// assert_eq!(
453 /// client.get(url).await?.success().unwrap_err().to_string(),
454 /// "expected a success (2xx) status code, but got 404 Not Found"
455 /// );
456 /// Ok(())
457 /// });
458 ///
459 /// with_server(Status::Ok, |url| async move {
460 /// let client = Client::new(client_config());
461 /// assert!(client.get(url).await?.success().is_ok());
462 /// Ok(())
463 /// });
464 /// ```
465 pub fn success(self) -> Result<Self, UnexpectedStatusError> {
466 match self.status() {
467 Some(status) if status.is_success() => Ok(self),
468 _ => Err(self.into()),
469 }
470 }
471
472 /// Detach the response body as an owned, `'static` value.
473 ///
474 /// Returns `None` if there is no body to take — neither an override has been installed nor
475 /// a transport-backed body is available. Subsequent calls return `None`. Callers who want
476 /// to wrap-and-replace the body (e.g. tee through a cache) compose this with
477 /// [`ConnExt::set_response_body`][crate::ConnExt::set_response_body]; the conn's
478 /// body slot is empty between the two calls.
479 ///
480 /// For a transport-backed body, this moves the transport into the returned
481 /// `ResponseBody<'static>`. Drop on that value drains-and-pools (keepalive) or closes
482 /// (otherwise) the transport via a spawned task; [`ResponseBody::recycle`] is the
483 /// `await`-able variant. For an override body, the inner [`Body`] is moved out and any
484 /// leftover transport on the conn is recycled immediately.
485 #[must_use]
486 pub fn take_response_body(&mut self) -> Option<ResponseBody<'static>> {
487 let encoding = encoding(&self.response_headers);
488 if let Some(body) = self.body_override.take() {
489 return Some(OverrideBody::new(body, encoding, self.context.config()).into());
490 }
491
492 let cleanup = self.build_cleanup_context();
493 let received = self.take_received_body(false)?;
494 Some(ResponseBody::received_owned(received, cleanup))
495 }
496
497 /// Build a [`CleanupContext`] capturing the runtime and (if keepalive + pool configured)
498 /// the pool + origin to insert into. Single source of truth for "what should happen to
499 /// this conn's transport when its body is released" — both the on_completion callback
500 /// wired into the body and the [`ResponseBody::recycle`] / `Drop` paths consume clones
501 /// of this same context, so the user-driven and Drop-driven release paths agree.
502 fn build_cleanup_context(&self) -> CleanupContext {
503 // Only pool a transport whose response head we actually received (`status.is_some()`): a
504 // conn abandoned before the response — a timeout or transport error mid-request — has an
505 // empty `response_headers`, which `is_keep_alive` would read as persistent and recycle a
506 // half-spent connection into the pool, poisoning the next request that reuses it.
507 let h1_pool_origin = if self.status.is_some()
508 && self.is_keep_alive()
509 && let Some(pool) = self.client.pool().cloned()
510 {
511 Some((pool, self.url.origin()))
512 } else {
513 None
514 };
515
516 CleanupContext {
517 runtime: self.client.connector().runtime(),
518 h1_pool_origin,
519 h1_idle_timeout: self.client.h1_idle_timeout(),
520 }
521 }
522
523 /// Detach the transport-backed receive side of this conn as an owned `ReceivedBody`.
524 ///
525 /// Returns `None` when no transport is attached.
526 ///
527 /// `cleanup: true` wires a spawn-on-End callback inside the body for callers that hand
528 /// the body off without awaiting it (`From<Conn> for Body`). `cleanup: false` is for
529 /// callers that drive the body to End themselves and release the transport inline in
530 /// their own poll loop — `take_response_body` does this so callers get a "transport is
531 /// settled when read_to_end returns Ok(0)" guarantee instead of racing a spawned task.
532 pub(crate) fn take_received_body(
533 &mut self,
534 cleanup: bool,
535 ) -> Option<ReceivedBody<'static, Box<dyn Transport>>> {
536 let _ = self.finalize_headers();
537 let transport = self.transport.take()?;
538
539 let on_completion = cleanup.then(|| {
540 let cleanup = self.build_cleanup_context();
541 Box::new(move |transport| cleanup.handoff(transport))
542 as Box<dyn FnOnce(Box<dyn Transport>) + Send + Sync + 'static>
543 });
544
545 Some(
546 ReceivedBody::new(
547 self.response_content_length(),
548 mem::take(&mut self.buffer),
549 transport,
550 self.response_body_state,
551 on_completion,
552 encoding(&self.response_headers),
553 )
554 .with_protocol_session(self.protocol_session.clone()),
555 )
556 }
557
558 /// Returns this conn to the connection pool if it is keepalive, and
559 /// closes it otherwise. This will happen asynchronously as a spawned
560 /// task when the conn is dropped, but calling it explicitly allows
561 /// you to block on it and control where it happens.
562 pub async fn recycle(mut self) {
563 if let Some(rb) = self.take_response_body() {
564 rb.recycle().await;
565 }
566 }
567
568 /// attempts to retrieve the connected peer address
569 pub fn peer_addr(&self) -> Option<SocketAddr> {
570 self.transport
571 .as_ref()
572 .and_then(|t| t.peer_addr().ok().flatten())
573 }
574
575 /// add state to the client conn and return self
576 pub fn with_state<T: Send + Sync + 'static>(mut self, state: T) -> Self {
577 self.insert_state(state);
578 self
579 }
580
581 /// add state to the client conn, returning any previously set state of this type
582 pub fn insert_state<T: Send + Sync + 'static>(&mut self, state: T) -> Option<T> {
583 self.state.insert(state)
584 }
585
586 /// borrow state
587 pub fn state<T: Send + Sync + 'static>(&self) -> Option<&T> {
588 self.state.get()
589 }
590
591 /// borrow state mutably
592 pub fn state_mut<T: Send + Sync + 'static>(&mut self) -> Option<&mut T> {
593 self.state.get_mut()
594 }
595
596 /// take state
597 pub fn take_state<T: Send + Sync + 'static>(&mut self) -> Option<T> {
598 self.state.take()
599 }
600}