iroh_http_core/http/client.rs
1//! Outgoing HTTP request — pure-Rust `fetch_request()` implementation.
2//!
3//! HTTP/1.1 framing is delegated entirely to hyper. Iroh's QUIC stream pair
4//! is wrapped in `IrohStream` and handed to hyper's client connection API.
5//!
6//! Slice D (#186) split the original FFI-shaped `fetch(endpoint, &str, ...)`
7//! into two layers:
8//!
9//! - [`fetch_request`] — pure-Rust API: takes a fully-formed
10//! [`hyper::Request<Body>`] and an [`iroh::EndpointAddr`], returns
11//! [`hyper::Response<Body>`] with a typed [`FetchError`]. No `u64`
12//! handles, no `BodyReader`, no string parsing of error messages, no
13//! imports from `crate::ffi`.
14//! - [`crate::ffi::fetch::fetch`] — FFI-shaped wrapper that builds the
15//! `Request<Body>` from flat strings, calls [`fetch_request`], and
16//! translates the response into a [`crate::FfiResponse`].
17
18use hyper_util::rt::TokioIo;
19
20use crate::{
21 http::{server::stack::StackConfig, transport::io::IrohStream},
22 Body, IrohEndpoint, ALPN,
23};
24
25// ── Typed fetch error ────────────────────────────────────────────────────────
26
27/// Typed error returned by the pure-Rust [`fetch_request`] API.
28///
29/// Header-oversize detection lives **above** this layer in
30/// [`crate::ffi::fetch`], which compares the assembled response head
31/// byte-count against the endpoint's `max_header_size`. That check is
32/// deterministic and does not depend on hyper's error wording, so this
33/// enum no longer disambiguates header parse failures from other
34/// transport errors — they all surface as `ConnectionFailed`.
35#[derive(Debug)]
36#[non_exhaustive]
37pub enum FetchError {
38 /// Connection setup, hyper handshake, or send-request transport
39 /// failure. Wraps the underlying [`hyper::Error`] when one is
40 /// available so callers can downcast instead of substring-matching.
41 ConnectionFailed {
42 detail: String,
43 source: Option<hyper::Error>,
44 },
45 /// Response head exceeded the endpoint's `max_header_size` budget.
46 /// Produced only by [`crate::ffi::fetch`]'s post-receive byte-count
47 /// check; [`fetch_request`] itself never emits this variant.
48 HeaderTooLarge { detail: String },
49 /// Response body exceeded the configured byte limit. Surfaced by the
50 /// FFI wrapper after the body is drained.
51 BodyTooLarge,
52 /// `cfg.timeout` elapsed before the response head arrived.
53 Timeout,
54 /// Caller dropped the future or signalled cancellation via the FFI
55 /// fetch token.
56 Cancelled,
57 /// Bug or unexpected internal failure (request build, body wrap, …).
58 Internal(String),
59}
60
61impl std::fmt::Display for FetchError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 FetchError::ConnectionFailed { detail, .. } => {
65 write!(f, "connection failed: {detail}")
66 }
67 FetchError::HeaderTooLarge { detail } => {
68 write!(f, "response header too large: {detail}")
69 }
70 FetchError::BodyTooLarge => f.write_str("response body too large"),
71 FetchError::Timeout => f.write_str("request timed out"),
72 FetchError::Cancelled => f.write_str("request cancelled"),
73 FetchError::Internal(msg) => write!(f, "internal error: {msg}"),
74 }
75 }
76}
77
78impl std::error::Error for FetchError {
79 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
80 match self {
81 FetchError::ConnectionFailed {
82 source: Some(s), ..
83 } => Some(s),
84 _ => None,
85 }
86 }
87}
88
89// ── Pure-Rust fetch API ──────────────────────────────────────────────────────
90
91/// Pure-Rust outbound entry — the canonical client API.
92///
93/// Establishes (or reuses, via [`crate::http::transport::pool`]) an Iroh
94/// QUIC connection to `addr`, runs hyper's HTTP/1.1 client handshake on
95/// a freshly opened bidirectional stream, dispatches `req` through the
96/// shared client tower stack ([`crate::http::server::stack::build_client_stack`]),
97/// and returns the response.
98///
99/// The returned [`hyper::Response<Body>`] streams its body lazily; the
100/// caller is responsible for draining it.
101///
102/// # Errors
103///
104/// Returns [`FetchError::Timeout`] if `cfg.timeout` is set and elapsed
105/// before the response head arrived. Connection / handshake / transport
106/// failures map to [`FetchError::ConnectionFailed`].
107pub async fn fetch_request(
108 endpoint: &IrohEndpoint,
109 addr: &iroh::EndpointAddr,
110 req: hyper::Request<Body>,
111 cfg: &StackConfig,
112) -> Result<hyper::Response<Body>, FetchError> {
113 let work = async {
114 let node_id = addr.id;
115 let ep_raw = endpoint.raw().clone();
116 let addr_clone = addr.clone();
117 let max_header_size = endpoint.max_header_size();
118
119 let pooled = endpoint
120 .pool()
121 .get_or_connect(node_id, ALPN, || async move {
122 ep_raw
123 .connect(addr_clone, ALPN)
124 .await
125 .map_err(|e| format!("connect: {e}"))
126 })
127 .await
128 .map_err(|e| FetchError::ConnectionFailed {
129 detail: e,
130 source: None,
131 })?;
132
133 let conn = pooled.conn.clone();
134
135 let (send, recv) = conn
136 .open_bi()
137 .await
138 .map_err(|e| FetchError::ConnectionFailed {
139 detail: format!("open_bi: {e}"),
140 source: None,
141 })?;
142 let io = TokioIo::new(IrohStream::new(send, recv));
143
144 let (sender, conn_task) = hyper::client::conn::http1::Builder::new()
145 // hyper requires max_buf_size >= 8192; clamp upward so small
146 // max_header_size values don't panic. Header-size enforcement
147 // happens at the byte-count check in `ffi::fetch` after the
148 // response is returned (deterministic, framing-independent).
149 .max_buf_size(max_header_size.max(8192))
150 .max_headers(128)
151 .handshake::<_, Body>(io)
152 .await
153 .map_err(|e| FetchError::ConnectionFailed {
154 detail: format!("hyper handshake: {e}"),
155 source: Some(e),
156 })?;
157
158 // Drive the connection state machine in the background.
159 tokio::spawn(conn_task);
160
161 // Dispatch through the shared client stack (Slice B / #184).
162 use tower::ServiceExt;
163 let svc = crate::http::server::stack::build_client_stack(sender, cfg);
164 svc.oneshot(req)
165 .await
166 .map_err(|e| FetchError::ConnectionFailed {
167 detail: format!("send_request: {e}"),
168 source: Some(e),
169 })
170 };
171
172 match cfg.timeout {
173 Some(t) => match tokio::time::timeout(t, work).await {
174 Ok(r) => r,
175 Err(_) => Err(FetchError::Timeout),
176 },
177 None => work.await,
178 }
179}
180
181// `extract_path` and the hyper-body→channel pumps moved to `ffi/fetch.rs`
182// and `ffi/pumps.rs` respectively (Slice D, #186). They were FFI plumbing
183// in shape and use; keeping them in `mod http` was the last reason this
184// module had to import from `crate::ffi`.