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