Skip to main content

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`.