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. Non-success responses retain at most 64 KiB of body bytes,
42/// with a truncation marker only after observing excess data. Partial diagnostic
43/// text survives body read failures; the client's timeout policy governs reads.
44#[derive(Clone, Debug)]
45pub struct ReqwestClient(Built);
46
47/// The process-wide client, erased behind [`DynHttpClient`].
48pub fn shared() -> DynHttpClient {
49    DynHttpClient::new(ReqwestClient::default())
50}
51
52/// What building the client produced: the client, or why reqwest refused.
53#[derive(Clone, Debug)]
54enum Built {
55    Client(Arc<reqwest::Client>),
56    /// The shared client could not be built: every send reports this, so
57    /// the failure surfaces where the first request is made rather than
58    /// where the client was named.
59    Failed(Arc<reqwest::Error>),
60}
61
62impl Default for ReqwestClient {
63    /// The process-wide client, built on first use. It never panics: a
64    /// client reqwest cannot build reports the build error on every send.
65    fn default() -> Self {
66        fn build() -> ReqwestClient {
67            let built = reqwest::Client::builder().build();
68            #[cfg(all(feature = "rustls", not(target_family = "wasm")))]
69            let built = built.or_else(with_bundled_roots);
70            ReqwestClient(match built {
71                Ok(client) => Built::Client(Arc::new(client)),
72                Err(error) => Built::Failed(Arc::new(error)),
73            })
74        }
75        #[cfg(not(target_family = "wasm"))]
76        {
77            static SHARED: std::sync::LazyLock<ReqwestClient> = std::sync::LazyLock::new(build);
78            SHARED.clone()
79        }
80        #[cfg(target_family = "wasm")]
81        {
82            thread_local! {
83                static SHARED: ReqwestClient = build();
84            }
85            SHARED.with(Clone::clone)
86        }
87    }
88}
89
90impl ReqwestClient {
91    /// The reqwest client, or `None` for the shared client when reqwest
92    /// could not build it.
93    pub fn inner(&self) -> Option<&reqwest::Client> {
94        match &self.0 {
95            Built::Client(client) => Some(client),
96            Built::Failed(_) => None,
97        }
98    }
99
100    /// Whether `self` and `other` are clones of one client.
101    #[cfg(test)]
102    fn same(&self, other: &Self) -> bool {
103        match (&self.0, &other.0) {
104            (Built::Client(a), Built::Client(b)) => Arc::ptr_eq(a, b),
105            (Built::Failed(a), Built::Failed(b)) => Arc::ptr_eq(a, b),
106            _ => false,
107        }
108    }
109}
110
111/// The shared client trusting only the bundled Mozilla roots, after the
112/// default build failed with `error`. That build differs only in reading
113/// the system's root store, which a host without CA certificates lacks.
114/// Returns `error` when this build fails too.
115#[cfg(all(feature = "rustls", not(target_family = "wasm")))]
116fn with_bundled_roots(error: reqwest::Error) -> reqwest::Result<reqwest::Client> {
117    let roots = webpki_root_certs::TLS_SERVER_ROOT_CERTS
118        .iter()
119        .filter_map(|der| reqwest::Certificate::from_der(der).ok());
120    let Ok(client) = reqwest::Client::builder().tls_certs_only(roots).build() else {
121        return Err(error);
122    };
123    tracing::warn!(
124        "the system's CA certificates could not be loaded ({}), so rig trusts its bundled \
125         Mozilla roots; install the system's CA certificates, or set SSL_CERT_FILE or \
126         SSL_CERT_DIR, to trust those instead",
127        Chain(&error)
128    );
129    Ok(client)
130}
131
132/// The error every send on an unbuilt client reports.
133fn unbuilt(error: &Arc<reqwest::Error>) -> Error {
134    Error::unbuilt(TransportBuildError(Arc::clone(error)))
135}
136
137impl From<reqwest::Client> for ReqwestClient {
138    fn from(client: reqwest::Client) -> Self {
139        Self(Built::Client(Arc::new(client)))
140    }
141}
142
143use bytes::Bytes;
144use rig_http::http_client::{
145    DynHttpClient, Error, HttpClientExt, LazyBody, MultipartForm, Request, Response, Result,
146    StreamingResponse, multipart::PartContent,
147};
148use rig_http::wasm_compat::*;
149use std::pin::Pin;
150use std::sync::Arc;
151
152/// A transport build failure that displays the source chain and retains the
153/// original reqwest error as its source.
154#[derive(Debug)]
155struct TransportBuildError(Arc<reqwest::Error>);
156
157impl std::fmt::Display for TransportBuildError {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        write!(
160            f,
161            "could not build the bundled reqwest transport: {}",
162            Chain(&*self.0)
163        )
164    }
165}
166
167/// An error followed by each of its sources, separated by `: `.
168struct Chain<'a>(&'a (dyn std::error::Error + 'static));
169
170impl std::fmt::Display for Chain<'_> {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        write!(f, "{}", self.0)?;
173        let mut source = self.0.source();
174        while let Some(cause) = source {
175            write!(f, ": {cause}")?;
176            source = cause.source();
177        }
178        Ok(())
179    }
180}
181
182impl std::error::Error for TransportBuildError {
183    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
184        Some(&*self.0)
185    }
186}
187
188/// Wrap a reqwest transport error as [`Error::Instance`], retaining its source.
189///
190/// HTTP status failures are handled separately to preserve response headers
191/// and bodies.
192pub fn from_reqwest(err: reqwest::Error) -> Error {
193    Error::instance(err)
194}
195
196/// Read at most 64 KiB of a failed response, retaining status and headers.
197/// Partial bodies survive read failures. An observed excess byte adds a
198/// truncation marker; the client's timeout policy still governs body reads.
199async fn non_success_status_error(response: reqwest::Response) -> Error {
200    use futures::StreamExt;
201
202    const LIMIT: usize = 64 * 1024;
203    let status = response.status();
204    let headers = response.headers().clone();
205    let mut stream = Box::pin(response.bytes_stream());
206    let mut bytes = Vec::new();
207    let mut truncated = false;
208    let mut failure = None;
209    while let Some(chunk) = stream.next().await {
210        match chunk {
211            Ok(chunk) => {
212                let remaining = LIMIT - bytes.len();
213                bytes.extend(chunk.iter().take(remaining));
214                if chunk.len() > remaining {
215                    truncated = true;
216                    break;
217                }
218            }
219            Err(error) => {
220                failure = Some(error);
221                break;
222            }
223        }
224    }
225    drop(stream);
226    let mut body = if bytes.is_empty() {
227        failure
228            .map(|error| format!("failed to read error response body: {error}"))
229            .unwrap_or_default()
230    } else {
231        decode_error_body(bytes, &headers).await
232    };
233    if truncated {
234        body.push_str("\n[Response body truncated after 64 KiB]");
235    }
236    Error::non_success_with_details(status, headers, body)
237}
238
239/// Keep reqwest's charset decoding for the bounded, in-memory body.
240#[cfg(not(target_family = "wasm"))]
241async fn decode_error_body(bytes: Vec<u8>, headers: &http::HeaderMap) -> String {
242    let mut response = http::Response::new(bytes);
243    *response.headers_mut() = headers.clone();
244    reqwest::Response::from(response)
245        .text()
246        .await
247        .unwrap_or_else(|error| format!("failed to read error response body: {error}"))
248}
249
250#[cfg(target_family = "wasm")]
251async fn decode_error_body(bytes: Vec<u8>, _headers: &http::HeaderMap) -> String {
252    let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(&bytes);
253    String::from_utf8_lossy(bytes).into_owned()
254}
255
256/// Keep successful bodies lazy and owned by the response on every executor.
257async fn into_response<U>(mut response: reqwest::Response) -> Result<Response<LazyBody<U>>>
258where
259    U: From<Bytes>,
260    U: WasmCompatSend + 'static,
261{
262    if !response.status().is_success() {
263        return Err(non_success_status_error(response).await);
264    }
265
266    let mut res = Response::builder().status(response.status());
267    if let Some(headers) = res.headers_mut() {
268        *headers = std::mem::take(response.headers_mut());
269    }
270
271    let body = async {
272        let bytes = response.bytes().await.map_err(Error::instance)?;
273        Ok(U::from(bytes))
274    };
275    #[cfg(not(target_family = "wasm"))]
276    let body = rig_http::runtime::bind(body)?;
277    let body: LazyBody<U> = Box::pin(body);
278    res.body(body).map_err(Error::Protocol)
279}
280
281fn streaming_head(response: &mut reqwest::Response) -> http::response::Builder {
282    #[cfg(not(target_family = "wasm"))]
283    let mut res = Response::builder()
284        .status(response.status())
285        .version(response.version());
286
287    #[cfg(target_family = "wasm")]
288    let mut res = Response::builder().status(response.status());
289
290    if let Some(hs) = res.headers_mut() {
291        *hs = std::mem::take(response.headers_mut());
292    }
293    res
294}
295
296/// Convert an already-sent streaming response into the transport-agnostic
297/// [`StreamingResponse`], rejecting non-success statuses with the
298/// headers-preserving error. The body retains the request's reactor context.
299async fn into_streaming_response(mut response: reqwest::Response) -> Result<StreamingResponse> {
300    if !response.status().is_success() {
301        return Err(non_success_status_error(response).await);
302    }
303    let res = streaming_head(&mut response);
304
305    use futures::StreamExt;
306    let stream = response
307        .bytes_stream()
308        .map(|chunk| chunk.map_err(Error::instance));
309    #[cfg(not(target_family = "wasm"))]
310    let stream = rig_http::runtime::bind_stream(stream)?;
311    let stream: Pin<Box<dyn WasmCompatSendStream<InnerItem = Result<Bytes>>>> = Box::pin(stream);
312    res.body(stream).map_err(Error::Protocol)
313}
314
315/// A part's content type was not a MIME type reqwest would accept.
316#[derive(Debug, thiserror::Error)]
317#[error("multipart part {part:?} has an unusable content type {content_type:?}: {source}")]
318struct InvalidPartContentType {
319    part: String,
320    content_type: String,
321    source: reqwest::Error,
322}
323
324/// Render a [`MultipartForm`] as a `reqwest::multipart::Form`.
325///
326/// Returns an error when a binary part has a content type reqwest rejects.
327pub fn multipart_form(value: MultipartForm) -> Result<reqwest::multipart::Form> {
328    let mut form = reqwest::multipart::Form::new();
329
330    for part in value.into_parts() {
331        let (name, content, filename, content_type) = part.into_pieces();
332        match content {
333            PartContent::Text(text) => {
334                form = form.text(name, text);
335            }
336            PartContent::Binary(bytes) => {
337                // A `Bytes` body is the same reusable part `Part::bytes` makes, uncopied.
338                let mut req_part = reqwest::multipart::Part::stream(bytes);
339                if let Some(content_type) = content_type.as_ref() {
340                    req_part = req_part.mime_str(content_type.as_ref()).map_err(|source| {
341                        Error::instance(InvalidPartContentType {
342                            part: name.clone(),
343                            content_type: content_type.as_ref().to_string(),
344                            source,
345                        })
346                    })?;
347                }
348
349                if let Some(filename) = filename {
350                    req_part = req_part.file_name(filename);
351                }
352
353                form = form.part(name, req_part);
354            }
355        }
356    }
357
358    Ok(form)
359}
360
361/// What a request carries: bytes, or a multipart form.
362enum Payload {
363    Bytes(Bytes),
364    Form(reqwest::multipart::Form),
365}
366
367/// A reqwest client, plain or with middleware.
368trait Execute: WasmCompatSend + WasmCompatSync {
369    /// The send of `parts` with `payload`, or why it cannot be sent.
370    fn execute(
371        &self,
372        parts: http::request::Parts,
373        payload: Result<Payload>,
374    ) -> Result<impl Future<Output = Result<reqwest::Response>> + WasmCompatSend + 'static>;
375}
376
377impl Execute for ReqwestClient {
378    fn execute(
379        &self,
380        parts: http::request::Parts,
381        payload: Result<Payload>,
382    ) -> Result<impl Future<Output = Result<reqwest::Response>> + WasmCompatSend + 'static> {
383        let client = match &self.0 {
384            Built::Client(client) => client,
385            Built::Failed(error) => return Err(unbuilt(error)),
386        };
387        let request = client
388            .request(parts.method, parts.uri.to_string())
389            .headers(parts.headers);
390        let request = match payload? {
391            Payload::Bytes(bytes) => request.body(bytes),
392            Payload::Form(form) => request.multipart(form),
393        };
394        Ok(async move { request.send().await.map_err(Error::instance) })
395    }
396}
397
398/// Sends `parts` with `payload` through `client` and reads the reply with
399/// `convert`. The reactor is selected on first poll and kept through the
400/// conversion.
401fn send_with<C, T, F>(
402    client: &C,
403    parts: http::request::Parts,
404    payload: Result<Payload>,
405    convert: impl FnOnce(reqwest::Response) -> F + WasmCompatSend + 'static,
406) -> impl Future<Output = Result<T>> + WasmCompatSend + 'static
407where
408    C: Execute,
409    F: Future<Output = Result<T>> + WasmCompatSend,
410{
411    let sent = client.execute(parts, payload);
412    async move {
413        let sent = sent?;
414        let operation = async move { convert(sent.await?).await };
415        #[cfg(not(target_family = "wasm"))]
416        let operation = rig_http::runtime::bind(operation)?;
417        operation.await
418    }
419}
420
421/// [`HttpClientExt`] for a client that implements [`Execute`].
422macro_rules! http_client_ext {
423    ($client:ty) => {
424        impl HttpClientExt for $client {
425            fn send<T, U>(
426                &self,
427                req: Request<T>,
428            ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
429            where
430                T: Into<Bytes>,
431                U: From<Bytes> + WasmCompatSend + 'static,
432            {
433                let (parts, body) = req.into_parts();
434                let payload = Ok(Payload::Bytes(body.into()));
435                send_with(self, parts, payload, into_response::<U>)
436            }
437
438            fn send_multipart<U>(
439                &self,
440                req: Request<MultipartForm>,
441            ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
442            where
443                U: From<Bytes> + WasmCompatSend + 'static,
444            {
445                let (parts, form) = req.into_parts();
446                // Reject invalid MIME types locally rather than sending incomplete metadata.
447                let payload = multipart_form(form).map(Payload::Form);
448                send_with(self, parts, payload, into_response::<U>)
449            }
450
451            fn send_streaming<T>(
452                &self,
453                req: Request<T>,
454            ) -> impl Future<Output = Result<StreamingResponse>> + WasmCompatSend
455            where
456                T: Into<Bytes> + WasmCompatSend,
457            {
458                let (parts, body) = req.into_parts();
459                let payload = Ok(Payload::Bytes(body.into()));
460                send_with(self, parts, payload, into_streaming_response)
461            }
462        }
463    };
464}
465http_client_ext!(ReqwestClient);
466
467#[cfg(any(
468    feature = "reqwest-middleware-rustls",
469    feature = "reqwest-middleware-native-tls"
470))]
471mod middleware;
472#[cfg(any(
473    feature = "reqwest-middleware-rustls",
474    feature = "reqwest-middleware-native-tls"
475))]
476pub use middleware::ReqwestMiddlewareClient;
477
478// Compile-time thread-safety contract: the transport handle is shared across
479// threads by every host runtime.
480#[cfg(not(target_family = "wasm"))]
481const _: fn() = || {
482    fn assert_send_sync_static<T: Send + Sync + 'static>() {}
483    assert_send_sync_static::<ReqwestClient>();
484};
485
486#[cfg(test)]
487mod tests;