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/// The most of a non-success reply's body its error keeps.
197const ERROR_BODY_LIMIT: usize = 64 * 1024;
198
199/// Read at most 64 KiB of a failed response, retaining status and headers.
200/// The client's timeout policy still governs body reads.
201async fn non_success_status_error(response: reqwest::Response) -> Error {
202    let status = response.status();
203    let headers = response.headers().clone();
204    non_success_body_error(status, headers, &mut Box::pin(response.bytes_stream())).await
205}
206
207/// The error a non-success reply becomes, read from its body's chunks: its
208/// status and headers, and at most the first 64 KiB of the body decoded as
209/// text by its charset, with a truncation marker when there was more. The
210/// body is read until one byte past the limit or its first error; the bytes
211/// read before an error are kept, and with none the error is the text. The
212/// rest of `body` is left unread for the caller. Every transport that gives
213/// providers rig-reqwest's errors builds them here, so the same reply gives
214/// the same error whichever reads it.
215pub async fn non_success_body_error<S, B, E>(
216    status: http::StatusCode,
217    headers: http::HeaderMap,
218    body: &mut S,
219) -> Error
220where
221    S: futures::Stream<Item = std::result::Result<B, E>> + Unpin,
222    B: AsRef<[u8]>,
223    E: std::fmt::Display,
224{
225    use futures::StreamExt;
226
227    let mut bytes = Vec::new();
228    let mut failure = None;
229    while let Some(chunk) = body.next().await {
230        match chunk {
231            Ok(chunk) => {
232                // One byte past the limit is enough to know the body was cut.
233                let remaining = ERROR_BODY_LIMIT + 1 - bytes.len();
234                bytes.extend(chunk.as_ref().iter().take(remaining));
235                if bytes.len() > ERROR_BODY_LIMIT {
236                    break;
237                }
238            }
239            Err(error) => {
240                failure = Some(error.to_string());
241                break;
242            }
243        }
244    }
245    let kept = bytes.get(..ERROR_BODY_LIMIT).unwrap_or(&bytes);
246    let mut text = if kept.is_empty() {
247        failure
248            .map(|failure| format!("failed to read error response body: {failure}"))
249            .unwrap_or_default()
250    } else {
251        decode_error_body(kept.to_vec(), &headers).await
252    };
253    if bytes.len() > ERROR_BODY_LIMIT {
254        text.push_str("\n[Response body truncated after 64 KiB]");
255    }
256    Error::non_success_with_details(status, headers, text)
257}
258
259/// Keep reqwest's charset decoding for the bounded, in-memory body.
260#[cfg(not(target_family = "wasm"))]
261async fn decode_error_body(bytes: Vec<u8>, headers: &http::HeaderMap) -> String {
262    let mut response = http::Response::new(bytes);
263    *response.headers_mut() = headers.clone();
264    reqwest::Response::from(response)
265        .text()
266        .await
267        .unwrap_or_else(|error| format!("failed to read error response body: {error}"))
268}
269
270#[cfg(target_family = "wasm")]
271async fn decode_error_body(bytes: Vec<u8>, _headers: &http::HeaderMap) -> String {
272    let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(&bytes);
273    String::from_utf8_lossy(bytes).into_owned()
274}
275
276/// Keep successful bodies lazy and owned by the response on every executor.
277async fn into_response<U>(mut response: reqwest::Response) -> Result<Response<LazyBody<U>>>
278where
279    U: From<Bytes>,
280    U: WasmCompatSend + 'static,
281{
282    if !response.status().is_success() {
283        return Err(non_success_status_error(response).await);
284    }
285
286    let mut res = Response::builder().status(response.status());
287    if let Some(headers) = res.headers_mut() {
288        *headers = std::mem::take(response.headers_mut());
289    }
290
291    let body = async {
292        let bytes = response.bytes().await.map_err(Error::instance)?;
293        Ok(U::from(bytes))
294    };
295    #[cfg(not(target_family = "wasm"))]
296    let body = rig_http::runtime::bind(body)?;
297    let body: LazyBody<U> = Box::pin(body);
298    res.body(body).map_err(Error::Protocol)
299}
300
301fn streaming_head(response: &mut reqwest::Response) -> http::response::Builder {
302    #[cfg(not(target_family = "wasm"))]
303    let mut res = Response::builder()
304        .status(response.status())
305        .version(response.version());
306
307    #[cfg(target_family = "wasm")]
308    let mut res = Response::builder().status(response.status());
309
310    if let Some(hs) = res.headers_mut() {
311        *hs = std::mem::take(response.headers_mut());
312    }
313    res
314}
315
316/// Convert an already-sent streaming response into the transport-agnostic
317/// [`StreamingResponse`], rejecting non-success statuses with the
318/// headers-preserving error. The body retains the request's reactor context.
319async fn into_streaming_response(mut response: reqwest::Response) -> Result<StreamingResponse> {
320    if !response.status().is_success() {
321        return Err(non_success_status_error(response).await);
322    }
323    let res = streaming_head(&mut response);
324
325    use futures::StreamExt;
326    let stream = response
327        .bytes_stream()
328        .map(|chunk| chunk.map_err(Error::instance));
329    #[cfg(not(target_family = "wasm"))]
330    let stream = rig_http::runtime::bind_stream(stream)?;
331    let stream: Pin<Box<dyn WasmCompatSendStream<InnerItem = Result<Bytes>>>> = Box::pin(stream);
332    res.body(stream).map_err(Error::Protocol)
333}
334
335/// A part's content type was not a MIME type reqwest would accept.
336#[derive(Debug, thiserror::Error)]
337#[error("multipart part {part:?} has an unusable content type {content_type:?}: {source}")]
338struct InvalidPartContentType {
339    part: String,
340    content_type: String,
341    source: reqwest::Error,
342}
343
344/// Render a [`MultipartForm`] as a `reqwest::multipart::Form`.
345///
346/// Returns an error when a binary part has a content type reqwest rejects.
347pub fn multipart_form(value: MultipartForm) -> Result<reqwest::multipart::Form> {
348    let mut form = reqwest::multipart::Form::new();
349
350    for part in value.into_parts() {
351        let (name, content, filename, content_type) = part.into_pieces();
352        match content {
353            PartContent::Text(text) => {
354                form = form.text(name, text);
355            }
356            PartContent::Binary(bytes) => {
357                // A `Bytes` body is the same reusable part `Part::bytes` makes, uncopied.
358                let mut req_part = reqwest::multipart::Part::stream(bytes);
359                if let Some(content_type) = content_type.as_ref() {
360                    req_part = req_part.mime_str(content_type.as_ref()).map_err(|source| {
361                        Error::instance(InvalidPartContentType {
362                            part: name.clone(),
363                            content_type: content_type.as_ref().to_string(),
364                            source,
365                        })
366                    })?;
367                }
368
369                if let Some(filename) = filename {
370                    req_part = req_part.file_name(filename);
371                }
372
373                form = form.part(name, req_part);
374            }
375        }
376    }
377
378    Ok(form)
379}
380
381/// What a request carries: bytes, or a multipart form.
382enum Payload {
383    Bytes(Bytes),
384    Form(reqwest::multipart::Form),
385}
386
387/// A reqwest client, plain or with middleware.
388trait Execute: WasmCompatSend + WasmCompatSync {
389    /// The send of `parts` with `payload`, or why it cannot be sent.
390    fn execute(
391        &self,
392        parts: http::request::Parts,
393        payload: Result<Payload>,
394    ) -> Result<impl Future<Output = Result<reqwest::Response>> + WasmCompatSend + 'static>;
395}
396
397impl Execute for ReqwestClient {
398    fn execute(
399        &self,
400        parts: http::request::Parts,
401        payload: Result<Payload>,
402    ) -> Result<impl Future<Output = Result<reqwest::Response>> + WasmCompatSend + 'static> {
403        let client = match &self.0 {
404            Built::Client(client) => client,
405            Built::Failed(error) => return Err(unbuilt(error)),
406        };
407        let request = client
408            .request(parts.method, parts.uri.to_string())
409            .headers(parts.headers);
410        let request = match payload? {
411            Payload::Bytes(bytes) => request.body(bytes),
412            Payload::Form(form) => request.multipart(form),
413        };
414        Ok(async move { request.send().await.map_err(Error::instance) })
415    }
416}
417
418/// Sends `parts` with `payload` through `client` and reads the reply with
419/// `convert`. The reactor is selected on first poll and kept through the
420/// conversion.
421fn send_with<C, T, F>(
422    client: &C,
423    parts: http::request::Parts,
424    payload: Result<Payload>,
425    convert: impl FnOnce(reqwest::Response) -> F + WasmCompatSend + 'static,
426) -> impl Future<Output = Result<T>> + WasmCompatSend + 'static
427where
428    C: Execute,
429    F: Future<Output = Result<T>> + WasmCompatSend,
430{
431    let sent = client.execute(parts, payload);
432    async move {
433        let sent = sent?;
434        let operation = async move { convert(sent.await?).await };
435        #[cfg(not(target_family = "wasm"))]
436        let operation = rig_http::runtime::bind(operation)?;
437        operation.await
438    }
439}
440
441/// [`HttpClientExt`] for a client that implements [`Execute`].
442macro_rules! http_client_ext {
443    ($client:ty) => {
444        impl HttpClientExt for $client {
445            fn send<T, U>(
446                &self,
447                req: Request<T>,
448            ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
449            where
450                T: Into<Bytes>,
451                U: From<Bytes> + WasmCompatSend + 'static,
452            {
453                let (parts, body) = req.into_parts();
454                let payload = Ok(Payload::Bytes(body.into()));
455                send_with(self, parts, payload, into_response::<U>)
456            }
457
458            fn send_multipart<U>(
459                &self,
460                req: Request<MultipartForm>,
461            ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
462            where
463                U: From<Bytes> + WasmCompatSend + 'static,
464            {
465                let (parts, form) = req.into_parts();
466                // Reject invalid MIME types locally rather than sending incomplete metadata.
467                let payload = multipart_form(form).map(Payload::Form);
468                send_with(self, parts, payload, into_response::<U>)
469            }
470
471            fn send_streaming<T>(
472                &self,
473                req: Request<T>,
474            ) -> impl Future<Output = Result<StreamingResponse>> + WasmCompatSend
475            where
476                T: Into<Bytes> + WasmCompatSend,
477            {
478                let (parts, body) = req.into_parts();
479                let payload = Ok(Payload::Bytes(body.into()));
480                send_with(self, parts, payload, into_streaming_response)
481            }
482        }
483    };
484}
485http_client_ext!(ReqwestClient);
486
487#[cfg(any(
488    feature = "reqwest-middleware-rustls",
489    feature = "reqwest-middleware-native-tls"
490))]
491mod middleware;
492#[cfg(any(
493    feature = "reqwest-middleware-rustls",
494    feature = "reqwest-middleware-native-tls"
495))]
496pub use middleware::ReqwestMiddlewareClient;
497
498// Compile-time thread-safety contract: the transport handle is shared across
499// threads by every host runtime.
500#[cfg(not(target_family = "wasm"))]
501const _: fn() = || {
502    fn assert_send_sync_static<T: Send + Sync + 'static>() {}
503    assert_send_sync_static::<ReqwestClient>();
504};
505
506#[cfg(test)]
507mod tests;