Skip to main content

rig_reqwest/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![cfg_attr(
3    test,
4    allow(
5        clippy::expect_used,
6        clippy::indexing_slicing,
7        clippy::panic,
8        clippy::unwrap_used,
9        clippy::unreachable
10    )
11)]
12//! The bundled reqwest HTTP transport for Rig.
13//!
14//! [`ReqwestClient::default()`] is the process-wide client: built once on
15//! first use and shared by every clone, so every provider client made with
16//! it shares one connection pool. Construction never fails. On a host with
17//! no CA certificates, the `rustls` build trusts the bundled Mozilla roots
18//! instead. When reqwest still cannot build its client, every send reports
19//! that build failure in-band.
20//!
21//! Native requests and bodies enter the captured Tokio context on each poll
22//! ([`rig_http::runtime`]), using a lazy fallback when no runtime is current. Callers retain ownership;
23//! dropping an operation cancels local work, not accepted remote work. Hosts
24//! must keep their runtime driven with I/O and timers enabled until operations
25//! finish. Missing drivers can panic; a stopped runtime causes I/O failure.
26//!
27//! ```no_run
28//! use rig_reqwest::ReqwestClient;
29//!
30//! let shared = ReqwestClient::default();
31//! let custom = ReqwestClient::from(rig_reqwest::reqwest::Client::new());
32//! # let _ = (shared, custom);
33//! ```
34
35pub use reqwest;
36
37/// A reqwest client implementing [`HttpClientExt`].
38///
39/// [`Default`] is the process-wide client, built once and shared by every
40/// clone; `From<reqwest::Client>` wraps a configured client and keeps its
41/// connection pool.
42#[derive(Clone, Debug)]
43pub struct ReqwestClient(Built);
44
45/// The process-wide client, erased behind [`DynHttpClient`].
46pub fn shared() -> DynHttpClient {
47    DynHttpClient::new(ReqwestClient::default())
48}
49
50/// What building the client produced: the client, or why reqwest refused.
51#[derive(Clone, Debug)]
52enum Built {
53    Client(Arc<reqwest::Client>),
54    /// The shared client could not be built: every send reports this, so
55    /// the failure surfaces where the first request is made rather than
56    /// where the client was named.
57    Failed(Arc<reqwest::Error>),
58}
59
60impl Default for ReqwestClient {
61    /// The process-wide client, built on first use. It never panics: a
62    /// client reqwest cannot build reports the build error on every send.
63    fn default() -> Self {
64        fn build() -> ReqwestClient {
65            let built = reqwest::Client::builder().build();
66            #[cfg(all(feature = "rustls", not(target_family = "wasm")))]
67            let built = built.or_else(with_bundled_roots);
68            ReqwestClient(match built {
69                Ok(client) => Built::Client(Arc::new(client)),
70                Err(error) => Built::Failed(Arc::new(error)),
71            })
72        }
73        #[cfg(not(target_family = "wasm"))]
74        {
75            static SHARED: std::sync::LazyLock<ReqwestClient> = std::sync::LazyLock::new(build);
76            SHARED.clone()
77        }
78        #[cfg(target_family = "wasm")]
79        {
80            thread_local! {
81                static SHARED: ReqwestClient = build();
82            }
83            SHARED.with(Clone::clone)
84        }
85    }
86}
87
88impl ReqwestClient {
89    /// The reqwest client, or `None` for the shared client when reqwest
90    /// could not build it.
91    pub fn inner(&self) -> Option<&reqwest::Client> {
92        match &self.0 {
93            Built::Client(client) => Some(client),
94            Built::Failed(_) => None,
95        }
96    }
97
98    /// Whether `self` and `other` are clones of one client.
99    #[cfg(test)]
100    fn same(&self, other: &Self) -> bool {
101        match (&self.0, &other.0) {
102            (Built::Client(a), Built::Client(b)) => Arc::ptr_eq(a, b),
103            (Built::Failed(a), Built::Failed(b)) => Arc::ptr_eq(a, b),
104            _ => false,
105        }
106    }
107}
108
109/// The shared client trusting only the bundled Mozilla roots, after the
110/// default build failed with `error`. That build differs only in reading
111/// the system's root store, which a host without CA certificates lacks.
112/// Returns `error` when this build fails too.
113#[cfg(all(feature = "rustls", not(target_family = "wasm")))]
114fn with_bundled_roots(error: reqwest::Error) -> reqwest::Result<reqwest::Client> {
115    let roots = webpki_root_certs::TLS_SERVER_ROOT_CERTS
116        .iter()
117        .filter_map(|der| reqwest::Certificate::from_der(der).ok());
118    let Ok(client) = reqwest::Client::builder().tls_certs_only(roots).build() else {
119        return Err(error);
120    };
121    tracing::warn!(
122        "the system's CA certificates could not be loaded ({}), so rig trusts its bundled \
123         Mozilla roots; install the system's CA certificates, or set SSL_CERT_FILE or \
124         SSL_CERT_DIR, to trust those instead",
125        Chain(&error)
126    );
127    Ok(client)
128}
129
130/// The error every send on an unbuilt client reports.
131fn unbuilt(error: &Arc<reqwest::Error>) -> Error {
132    Error::unbuilt(TransportBuildError(Arc::clone(error)))
133}
134
135impl From<reqwest::Client> for ReqwestClient {
136    fn from(client: reqwest::Client) -> Self {
137        Self(Built::Client(Arc::new(client)))
138    }
139}
140
141use bytes::Bytes;
142use rig_http::http_client::{
143    DynHttpClient, Error, HttpClientExt, LazyBody, MultipartForm, Request, Response, Result,
144    StreamingResponse, multipart::PartContent,
145};
146use rig_http::wasm_compat::*;
147use std::pin::Pin;
148use std::sync::Arc;
149
150/// A transport build failure that displays the source chain and retains the
151/// original reqwest error as its source.
152#[derive(Debug)]
153struct TransportBuildError(Arc<reqwest::Error>);
154
155impl std::fmt::Display for TransportBuildError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        write!(
158            f,
159            "could not build the bundled reqwest transport: {}",
160            Chain(&*self.0)
161        )
162    }
163}
164
165/// An error followed by each of its sources, separated by `: `.
166struct Chain<'a>(&'a (dyn std::error::Error + 'static));
167
168impl std::fmt::Display for Chain<'_> {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        write!(f, "{}", self.0)?;
171        let mut source = self.0.source();
172        while let Some(cause) = source {
173            write!(f, ": {cause}")?;
174            source = cause.source();
175        }
176        Ok(())
177    }
178}
179
180impl std::error::Error for TransportBuildError {
181    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
182        Some(&*self.0)
183    }
184}
185
186/// Wrap a reqwest transport error as [`Error::Instance`], retaining its source.
187///
188/// HTTP status failures are handled separately to preserve response headers
189/// and bodies.
190pub fn from_reqwest(err: reqwest::Error) -> Error {
191    Error::instance(err)
192}
193
194/// Read the status, headers and body off a failed `reqwest::Response` and
195/// build a non-success error that preserves the headers.
196async fn non_success_status_error(response: reqwest::Response) -> Error {
197    let status = response.status();
198    let headers = response.headers().clone();
199    let body = response
200        .text()
201        .await
202        .unwrap_or_else(|error| format!("failed to read error response body: {error}"));
203    Error::non_success_with_details(status, headers, body)
204}
205
206/// Keep successful bodies lazy and owned by the response on every executor.
207async fn into_response<U>(mut response: reqwest::Response) -> Result<Response<LazyBody<U>>>
208where
209    U: From<Bytes>,
210    U: WasmCompatSend + 'static,
211{
212    if !response.status().is_success() {
213        return Err(non_success_status_error(response).await);
214    }
215
216    let mut res = Response::builder().status(response.status());
217    if let Some(headers) = res.headers_mut() {
218        *headers = std::mem::take(response.headers_mut());
219    }
220
221    let body = async {
222        let bytes = response.bytes().await.map_err(Error::instance)?;
223        Ok(U::from(bytes))
224    };
225    #[cfg(not(target_family = "wasm"))]
226    let body = rig_http::runtime::bind(body)?;
227    let body: LazyBody<U> = Box::pin(body);
228    res.body(body).map_err(Error::Protocol)
229}
230
231fn streaming_head(response: &mut reqwest::Response) -> http::response::Builder {
232    #[cfg(not(target_family = "wasm"))]
233    let mut res = Response::builder()
234        .status(response.status())
235        .version(response.version());
236
237    #[cfg(target_family = "wasm")]
238    let mut res = Response::builder().status(response.status());
239
240    if let Some(hs) = res.headers_mut() {
241        *hs = std::mem::take(response.headers_mut());
242    }
243    res
244}
245
246/// Convert an already-sent streaming response into the transport-agnostic
247/// [`StreamingResponse`], rejecting non-success statuses with the
248/// headers-preserving error. The body retains the request's reactor context.
249async fn into_streaming_response(mut response: reqwest::Response) -> Result<StreamingResponse> {
250    if !response.status().is_success() {
251        return Err(non_success_status_error(response).await);
252    }
253    let res = streaming_head(&mut response);
254
255    use futures::StreamExt;
256    let stream = response
257        .bytes_stream()
258        .map(|chunk| chunk.map_err(Error::instance));
259    #[cfg(not(target_family = "wasm"))]
260    let stream = rig_http::runtime::bind_stream(stream)?;
261    let stream: Pin<Box<dyn WasmCompatSendStream<InnerItem = Result<Bytes>>>> = Box::pin(stream);
262    res.body(stream).map_err(Error::Protocol)
263}
264
265/// A part's content type was not a MIME type reqwest would accept.
266#[derive(Debug, thiserror::Error)]
267#[error("multipart part {part:?} has an unusable content type {content_type:?}: {source}")]
268struct InvalidPartContentType {
269    part: String,
270    content_type: String,
271    source: reqwest::Error,
272}
273
274/// Render a [`MultipartForm`] as a `reqwest::multipart::Form`.
275///
276/// Returns an error when a binary part has a content type reqwest rejects.
277pub fn multipart_form(value: MultipartForm) -> Result<reqwest::multipart::Form> {
278    let mut form = reqwest::multipart::Form::new();
279
280    for part in value.into_parts() {
281        let (name, content, filename, content_type) = part.into_pieces();
282        match content {
283            PartContent::Text(text) => {
284                form = form.text(name, text);
285            }
286            PartContent::Binary(bytes) => {
287                // A `Bytes` body is the same reusable part `Part::bytes` makes, uncopied.
288                let mut req_part = reqwest::multipart::Part::stream(bytes);
289                if let Some(content_type) = content_type.as_ref() {
290                    req_part = req_part.mime_str(content_type.as_ref()).map_err(|source| {
291                        Error::instance(InvalidPartContentType {
292                            part: name.clone(),
293                            content_type: content_type.as_ref().to_string(),
294                            source,
295                        })
296                    })?;
297                }
298
299                if let Some(filename) = filename {
300                    req_part = req_part.file_name(filename);
301                }
302
303                form = form.part(name, req_part);
304            }
305        }
306    }
307
308    Ok(form)
309}
310
311/// What a request carries: bytes, or a multipart form.
312enum Payload {
313    Bytes(Bytes),
314    Form(reqwest::multipart::Form),
315}
316
317/// A reqwest client, plain or with middleware.
318trait Execute: WasmCompatSend + WasmCompatSync {
319    /// The send of `parts` with `payload`, or why it cannot be sent.
320    fn execute(
321        &self,
322        parts: http::request::Parts,
323        payload: Result<Payload>,
324    ) -> Result<impl Future<Output = Result<reqwest::Response>> + WasmCompatSend + 'static>;
325}
326
327impl Execute for ReqwestClient {
328    fn execute(
329        &self,
330        parts: http::request::Parts,
331        payload: Result<Payload>,
332    ) -> Result<impl Future<Output = Result<reqwest::Response>> + WasmCompatSend + 'static> {
333        let client = match &self.0 {
334            Built::Client(client) => client,
335            Built::Failed(error) => return Err(unbuilt(error)),
336        };
337        let request = client
338            .request(parts.method, parts.uri.to_string())
339            .headers(parts.headers);
340        let request = match payload? {
341            Payload::Bytes(bytes) => request.body(bytes),
342            Payload::Form(form) => request.multipart(form),
343        };
344        Ok(async move { request.send().await.map_err(Error::instance) })
345    }
346}
347
348/// Sends `parts` with `payload` through `client` and reads the reply with
349/// `convert`. The reactor is selected on first poll and kept through the
350/// conversion.
351fn send_with<C, T, F>(
352    client: &C,
353    parts: http::request::Parts,
354    payload: Result<Payload>,
355    convert: impl FnOnce(reqwest::Response) -> F + WasmCompatSend + 'static,
356) -> impl Future<Output = Result<T>> + WasmCompatSend + 'static
357where
358    C: Execute,
359    F: Future<Output = Result<T>> + WasmCompatSend,
360{
361    let sent = client.execute(parts, payload);
362    async move {
363        let sent = sent?;
364        let operation = async move { convert(sent.await?).await };
365        #[cfg(not(target_family = "wasm"))]
366        let operation = rig_http::runtime::bind(operation)?;
367        operation.await
368    }
369}
370
371/// [`HttpClientExt`] for a client that implements [`Execute`].
372macro_rules! http_client_ext {
373    ($client:ty) => {
374        impl HttpClientExt for $client {
375            fn send<T, U>(
376                &self,
377                req: Request<T>,
378            ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
379            where
380                T: Into<Bytes>,
381                U: From<Bytes> + WasmCompatSend + 'static,
382            {
383                let (parts, body) = req.into_parts();
384                let payload = Ok(Payload::Bytes(body.into()));
385                send_with(self, parts, payload, into_response::<U>)
386            }
387
388            fn send_multipart<U>(
389                &self,
390                req: Request<MultipartForm>,
391            ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
392            where
393                U: From<Bytes> + WasmCompatSend + 'static,
394            {
395                let (parts, form) = req.into_parts();
396                // Reject invalid MIME types locally rather than sending incomplete metadata.
397                let payload = multipart_form(form).map(Payload::Form);
398                send_with(self, parts, payload, into_response::<U>)
399            }
400
401            fn send_streaming<T>(
402                &self,
403                req: Request<T>,
404            ) -> impl Future<Output = Result<StreamingResponse>> + WasmCompatSend
405            where
406                T: Into<Bytes> + WasmCompatSend,
407            {
408                let (parts, body) = req.into_parts();
409                let payload = Ok(Payload::Bytes(body.into()));
410                send_with(self, parts, payload, into_streaming_response)
411            }
412        }
413    };
414}
415http_client_ext!(ReqwestClient);
416
417#[cfg(any(
418    feature = "reqwest-middleware-rustls",
419    feature = "reqwest-middleware-native-tls"
420))]
421mod middleware;
422#[cfg(any(
423    feature = "reqwest-middleware-rustls",
424    feature = "reqwest-middleware-native-tls"
425))]
426pub use middleware::ReqwestMiddlewareClient;
427
428// Compile-time thread-safety contract: the transport handle is shared across
429// threads by every host runtime.
430#[cfg(not(target_family = "wasm"))]
431const _: fn() = || {
432    fn assert_send_sync_static<T: Send + Sync + 'static>() {}
433    assert_send_sync_static::<ReqwestClient>();
434};
435
436#[cfg(test)]
437mod tests;