Skip to main content

allama_http/
reqwest.rs

1use std::error::Error as StdError;
2use std::fmt;
3use std::io::ErrorKind;
4use std::time::Duration;
5
6use futures_util::StreamExt;
7
8use crate::{
9    Headers, HttpRequest, HttpResponse, HttpTransport, Method, SendFuture, TransportError,
10    TransportErrorKind,
11};
12
13#[cfg(feature = "reqwest-middleware")]
14type Client = reqwest_middleware::ClientWithMiddleware;
15#[cfg(not(feature = "reqwest-middleware"))]
16type Client = reqwest::Client;
17
18/// The connect timeout (TCP and TLS handshake) of [`ReqwestTransport::new`]:
19/// 10 s.
20///
21/// Not applied on `wasm32`, where `reqwest` sends through the host's `fetch`
22/// and has no connect timeout, and never applied to a client you pass to
23/// [`ReqwestTransport::from_client`].
24pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
25
26/// An [`HttpTransport`] backed by `reqwest`.
27///
28/// # Redirects
29///
30/// The client [`new`](Self::new) builds never follows a redirect: the
31/// `3xx` response comes back as it is, and the bundled providers report it
32/// as an [`allama::Error::UnexpectedResponse`] naming the target host
33/// ([`redirect_error`](crate::redirect_error)). An LLM API never answers a
34/// request with a redirect, and following one is unsafe: `reqwest` strips
35/// only `Authorization` (and cookies) on a redirect to another host, so
36/// `x-api-key`, `x-goog-api-key`, `api-key` and gateway credential headers
37/// would reach the new host, a `307` or `308` would replay the prompt to
38/// it, and an `https` to `http` downgrade would be followed. A redirect
39/// would also bypass any allowlist the application checks a base URL
40/// against.
41///
42/// A client you pass to [`from_client`](Self::from_client) or
43/// `from_middleware` (feature `reqwest-middleware`) keeps its own redirect
44/// policy, which is `reqwest`'s default (up to 10 redirects) unless you set
45/// one: build it with `.redirect(reqwest::redirect::Policy::none())` to keep
46/// this guarantee. On `wasm32`, `reqwest` sends through the host's `fetch`,
47/// whose redirect handling applies and cannot be changed from here.
48///
49/// With the `reqwest-middleware` feature, requests go through a
50/// `reqwest-middleware` client (`from_middleware`
51/// takes your own); the `http-tracing` feature also adds
52/// `reqwest_tracing::TracingMiddleware` to the clients
53/// [`new`](Self::new) and [`from_client`](Self::from_client) build.
54///
55/// Clone is cheap: `reqwest` clients are reference counted.
56#[derive(Clone)]
57pub struct ReqwestTransport {
58    client: Client,
59}
60
61impl fmt::Debug for ReqwestTransport {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.debug_struct("ReqwestTransport").finish_non_exhaustive()
64    }
65}
66
67impl Default for ReqwestTransport {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl ReqwestTransport {
74    /// A transport with a default `reqwest` client that never follows
75    /// redirects (see [Redirects](Self#redirects)), with a
76    /// [`DEFAULT_CONNECT_TIMEOUT`] (both except on `wasm32`).
77    ///
78    /// With the `rustls-ring` feature, it first installs `rustls`' `ring`
79    /// crypto provider as the process default, unless the application
80    /// installed one. A client you build yourself for
81    /// [`from_client`](Self::from_client) needs a provider installed the
82    /// same way (`rustls::crypto::ring::default_provider().install_default()`)
83    /// before it is built.
84    ///
85    /// # Panics
86    ///
87    /// When the TLS backend cannot be initialized, like
88    /// `reqwest::Client::new`.
89    #[must_use]
90    pub fn new() -> Self {
91        // `Err` means a provider is already installed, which is then used.
92        #[cfg(all(feature = "rustls-ring", not(target_arch = "wasm32")))]
93        let _ = rustls::crypto::ring::default_provider().install_default();
94        let builder = reqwest::Client::builder();
95        #[cfg(not(target_arch = "wasm32"))]
96        let builder = builder
97            .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
98            .redirect(reqwest::redirect::Policy::none());
99        Self::from_client(builder.build().expect("failed to build the reqwest client"))
100    }
101
102    /// A transport over `client`, whose timeouts, redirect policy and
103    /// other settings are used as they are (wrapped with tracing middleware
104    /// under `http-tracing`). Unlike [`new`](Self::new)'s, a client from
105    /// `reqwest::Client::new()` follows redirects: see
106    /// [Redirects](Self#redirects).
107    ///
108    /// ```
109    /// use std::time::Duration;
110    /// use allama_http::ReqwestTransport;
111    ///
112    /// let client = reqwest::Client::builder()
113    ///     .redirect(reqwest::redirect::Policy::none())
114    ///     .connect_timeout(Duration::from_secs(5))
115    ///     .build()?;
116    /// let transport = ReqwestTransport::from_client(client);
117    /// # let _ = transport;
118    /// # Ok::<(), reqwest::Error>(())
119    /// ```
120    #[must_use]
121    pub fn from_client(client: reqwest::Client) -> Self {
122        #[cfg(feature = "reqwest-middleware")]
123        let client = {
124            let builder = reqwest_middleware::ClientBuilder::new(client);
125            #[cfg(feature = "http-tracing")]
126            let builder = builder.with(reqwest_tracing::TracingMiddleware::<
127                reqwest_tracing::SpanBackendWithUrl,
128            >::new());
129            builder.build()
130        };
131        Self { client }
132    }
133
134    /// A transport over a middleware client, used unchanged (no tracing
135    /// middleware is added, even under `http-tracing`), its redirect policy
136    /// included: see [Redirects](Self#redirects).
137    #[cfg(feature = "reqwest-middleware")]
138    #[must_use]
139    pub fn from_middleware(client: reqwest_middleware::ClientWithMiddleware) -> Self {
140        Self { client }
141    }
142}
143
144impl HttpTransport for ReqwestTransport {
145    fn send(&self, request: HttpRequest) -> SendFuture<'_> {
146        Box::pin(single_threaded(async move {
147            let built = self.build(request)?;
148            record_server(built.url());
149            let response = self.client.execute(built).await.map_err(send_error)?;
150            let status = response.status().as_u16();
151            let headers = response
152                .headers()
153                .iter()
154                // A value that is not visible ASCII (obs-text) is kept,
155                // decoded lossily, rather than dropped.
156                .map(|(name, value)| {
157                    (
158                        name.as_str(),
159                        String::from_utf8_lossy(value.as_bytes()).into_owned(),
160                    )
161                })
162                .collect::<Headers>();
163            let body = response
164                .bytes_stream()
165                .map(|chunk| chunk.map_err(|e| with_cause(e, TransportErrorKind::Body)));
166            Ok(HttpResponse::new(
167                status,
168                headers,
169                Box::pin(single_threaded(body)),
170            ))
171        }))
172    }
173}
174
175impl ReqwestTransport {
176    /// The `reqwest` request for `request`, its sensitive header values
177    /// marked so.
178    fn build(&self, request: HttpRequest) -> Result<reqwest::Request, TransportError> {
179        let method = match request.method {
180            Method::Get => reqwest::Method::GET,
181            Method::Post => reqwest::Method::POST,
182        };
183        let mut builder = self.client.request(method, &request.url);
184        for header in &request.headers.0 {
185            builder = match reqwest::header::HeaderValue::from_str(&header.value) {
186                // Marked sensitive, so `reqwest` and the `http` crate
187                // keep it out of their own `Debug` output.
188                Ok(mut value) if header.is_sensitive() => {
189                    value.set_sensitive(true);
190                    builder.header(header.name.as_str(), value)
191                }
192                _ => builder.header(header.name.as_str(), header.value.as_str()),
193            };
194        }
195        if let Some(body) = request.body {
196            builder = builder.body(body);
197        }
198        // Set on the built request: `reqwest-middleware` has no builder
199        // `timeout` on `wasm32`.
200        let mut built = builder
201            .build()
202            .map_err(|e| with_cause(e, TransportErrorKind::InvalidRequest))?;
203        *built.timeout_mut() = request.timeout;
204        Ok(built)
205    }
206}
207
208/// On `wasm32`, `reqwest` futures and streams wrap JS objects and are
209/// `!Send`. The JS runtimes `reqwest` targets there run them on one thread,
210/// so `SendWrapper` satisfies the `Send` bounds of [`SendFuture`] and
211/// [`Body`](crate::Body); it panics if the value is ever touched from
212/// another thread.
213#[cfg(target_arch = "wasm32")]
214fn single_threaded<T>(value: T) -> send_wrapper::SendWrapper<T> {
215    send_wrapper::SendWrapper::new(value)
216}
217
218#[cfg(not(target_arch = "wasm32"))]
219fn single_threaded<T>(value: T) -> T {
220    value
221}
222
223#[cfg(feature = "reqwest-middleware")]
224fn send_error(err: reqwest_middleware::Error) -> TransportError {
225    match err {
226        // `Reqwest` is transparent: its `source` skips the reqwest error.
227        reqwest_middleware::Error::Reqwest(inner) => with_cause(inner, TransportErrorKind::Connect),
228        // A middleware's message is its own text, which may quote the URL:
229        // redacted the same way. Its error is not attached: its chain is
230        // the middleware's own and may quote the URL too.
231        err @ reqwest_middleware::Error::Middleware(_) => {
232            let message = crate::config::redact_urls_in(&err.to_string()).into_owned();
233            classify(&err, TransportErrorKind::Connect, message)
234        }
235    }
236}
237
238/// Tell the call scope which server the request goes to, for the span's
239/// `server.address` and `server.port`.
240fn record_server(url: &reqwest::Url) {
241    use allama::wrappers::call_scope::{self, CallFact};
242
243    if let Some(host) = url.host_str() {
244        call_scope::record(CallFact::server(
245            host.to_owned(),
246            url.port_or_known_default(),
247        ));
248    }
249}
250
251#[cfg(not(feature = "reqwest-middleware"))]
252fn send_error(err: reqwest::Error) -> TransportError {
253    with_cause(err, TransportErrorKind::Connect)
254}
255
256/// The transport error for a `reqwest` failure, with its URL redacted and
257/// the redacted error attached as the source, so a caller can tell a TLS
258/// failure from a DNS one or a reset by downcasting it.
259fn with_cause(err: reqwest::Error, fallback: TransportErrorKind) -> TransportError {
260    let err = redact_url(err);
261    classify(&err, fallback, err.to_string()).with_source(err)
262}
263
264/// Drop the query string, fragment, user name and password from the URL a
265/// `reqwest` error shows: some APIs put the key in the query string
266/// (`?key=`), and a base URL can carry a password.
267fn redact_url(mut err: reqwest::Error) -> reqwest::Error {
268    if let Some(url) = err.url_mut() {
269        url.set_query(None);
270        url.set_fragment(None);
271        let _ = url.set_password(None);
272        let _ = url.set_username("");
273    }
274    err
275}
276
277/// Classify `err` (walking its sources) as a timeout, an unbuildable
278/// request, or `fallback`.
279fn classify(
280    err: &(dyn StdError + 'static),
281    fallback: TransportErrorKind,
282    message: String,
283) -> TransportError {
284    let mut kind = fallback;
285    let mut current = Some(err);
286    while let Some(err) = current {
287        if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
288            if reqwest_err.is_timeout() {
289                kind = TransportErrorKind::Timeout;
290                break;
291            }
292            if reqwest_err.is_builder() {
293                kind = TransportErrorKind::InvalidRequest;
294                break;
295            }
296        }
297        if let Some(io_err) = err.downcast_ref::<std::io::Error>()
298            && io_err.kind() == ErrorKind::TimedOut
299        {
300            kind = TransportErrorKind::Timeout;
301            break;
302        }
303        current = err.source();
304    }
305    TransportError::new(kind, message)
306}
307
308#[cfg(all(test, not(target_arch = "wasm32")))]
309mod tests {
310    use std::time::Duration;
311
312    use allama_conformance::{MockHttpResponse, TestHttpServer};
313
314    use super::*;
315
316    #[tokio::test]
317    async fn sends_requests_and_streams_bodies() {
318        let server = TestHttpServer::spawn([
319            MockHttpResponse::text(201, "hello").header("x-request-id", "req_1")
320        ])
321        .await;
322        let transport = ReqwestTransport::new();
323        let response = transport
324            .send(
325                HttpRequest::post(format!("{}/v1/items", server.url()), "{\"a\":1}")
326                    .header("authorization", "Bearer k")
327                    .timeout(Some(Duration::from_secs(5))),
328            )
329            .await
330            .unwrap();
331        assert_eq!(response.status, 201);
332        assert_eq!(response.headers.get("X-Request-Id"), Some("req_1"));
333        assert_eq!(response.text().await.unwrap(), "hello");
334
335        let recorded = &server.recorded_requests().await[0];
336        assert_eq!(
337            (recorded.method.as_str(), recorded.path.as_str()),
338            ("POST", "/v1/items")
339        );
340        assert_eq!(recorded.header("authorization"), Some("Bearer k"));
341        assert_eq!(recorded.body_json()["a"], 1);
342    }
343
344    /// With `rustls-ring` alone, `reqwest` ships no crypto provider and
345    /// panics when it builds a client unless one is installed.
346    #[cfg(feature = "rustls-ring")]
347    #[test]
348    fn rustls_ring_installs_a_crypto_provider() {
349        let _transport = ReqwestTransport::new();
350        assert!(rustls::crypto::CryptoProvider::get_default().is_some());
351        // A second transport finds the provider installed.
352        let _transport = ReqwestTransport::new();
353    }
354
355    #[tokio::test]
356    async fn failures_are_classified() {
357        let refused = ReqwestTransport::new()
358            .send(HttpRequest::get("http://127.0.0.1:1/"))
359            .await
360            .unwrap_err();
361        assert_eq!(refused.kind(), TransportErrorKind::Connect);
362
363        let unbuildable = ReqwestTransport::new()
364            .send(HttpRequest::get("not a url"))
365            .await
366            .unwrap_err();
367        assert_eq!(unbuildable.kind(), TransportErrorKind::InvalidRequest);
368    }
369
370    /// Credential headers are marked sensitive on the `reqwest` request,
371    /// so `reqwest` and `http` redact them in their own `Debug` output.
372    #[test]
373    fn sensitive_headers_are_marked_on_the_request() {
374        let built = ReqwestTransport::new()
375            .build(
376                HttpRequest::get("http://127.0.0.1:1/v1/models")
377                    .header("authorization", "Bearer SECRET")
378                    .header("x-api-key", "SECRET")
379                    .sensitive_header("x-route", "SECRET")
380                    .header("x-request-id", "req_1"),
381            )
382            .unwrap();
383        let headers = built.headers();
384        for name in ["authorization", "x-api-key", "x-route"] {
385            assert!(headers[name].is_sensitive(), "{name}");
386        }
387        assert!(!headers["x-request-id"].is_sensitive());
388        assert!(!format!("{built:?}").contains("SECRET"));
389    }
390
391    /// A response header value that is not visible ASCII (obs-text) is
392    /// kept, decoded lossily, not dropped.
393    #[tokio::test]
394    async fn obs_text_response_headers_are_kept() {
395        let server =
396            TestHttpServer::spawn([MockHttpResponse::text(200, "ok").header("x-region", "zürich")])
397                .await;
398        let response = ReqwestTransport::new()
399            .send(HttpRequest::get(format!("{}/v1/models", server.url())))
400            .await
401            .unwrap();
402        assert_eq!(response.headers.get("x-region"), Some("zürich"));
403    }
404
405    #[tokio::test]
406    async fn error_text_drops_the_query_string() {
407        let refused = ReqwestTransport::new()
408            .send(HttpRequest::get(
409                "http://user:pw@127.0.0.1:1/v1/models?key=SECRET",
410            ))
411            .await
412            .unwrap_err();
413        let text = refused.to_string();
414        assert!(!text.contains("SECRET"), "{text}");
415        assert!(!text.contains("pw"), "{text}");
416        assert!(!text.contains("user"), "{text}");
417        assert!(text.contains("127.0.0.1:1/v1/models"), "{text}");
418
419        // The redacted `reqwest` error is the source, and nothing in the
420        // chain shows the secrets.
421        let err = refused.into_error();
422        let source = std::error::Error::source(&err).expect("the cause is attached");
423        let cause = source
424            .downcast_ref::<reqwest::Error>()
425            .expect("the source is the reqwest error");
426        assert!(cause.is_connect() || cause.is_request(), "{cause:?}");
427        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
428        while let Some(link) = current {
429            for text in [link.to_string(), format!("{link:?}")] {
430                assert!(!text.contains("SECRET"), "{text}");
431                assert!(!text.contains("pw"), "{text}");
432                assert!(!text.contains("user"), "{text}");
433            }
434            current = link.source();
435        }
436    }
437
438    /// A key in the query string (Gemini's `?key=`) reaches neither the
439    /// redacted log, with or without the upstream message, nor `Display`.
440    #[tokio::test]
441    async fn query_string_key_stays_out_of_the_error() {
442        let err = ReqwestTransport::new()
443            .send(HttpRequest::get(
444                "http://127.0.0.1:1/v1beta/models/m:generateContent?key=secret",
445            ))
446            .await
447            .unwrap_err()
448            .into_error();
449        assert!(matches!(err, allama::Error::Transport { .. }), "{err:?}");
450        for text in [
451            err.as_log().to_string(),
452            err.as_log().with_message(10_000).to_string(),
453            serde_json::to_string(&err.as_log().with_message(10_000)).unwrap(),
454            err.to_string(),
455            format!("{err:?}"),
456        ] {
457            assert!(!text.contains("secret"), "{text}");
458        }
459        assert_eq!(
460            err.as_log().to_string(),
461            r#"kind=transport message="the request got no response""#
462        );
463    }
464}
465
466/// Run on `wasm32` with `wasm-bindgen-test`
467/// (`cargo test --target wasm32-unknown-unknown -p allama-http`): `reqwest`
468/// sends through the `fetch` of Node, against a Node HTTP server.
469#[cfg(all(test, target_arch = "wasm32"))]
470mod wasm_tests {
471    use std::time::Duration;
472
473    use wasm_bindgen::prelude::*;
474    use wasm_bindgen_test::wasm_bindgen_test;
475
476    use super::*;
477
478    #[wasm_bindgen(inline_js = r#"
479        export async function startServer() {
480            const http = await import("node:http");
481            const server = http.createServer((req, res) => {
482                if (req.url === "/slow") {
483                    setTimeout(() => res.end("late"), 1000);
484                    return;
485                }
486                let body = "";
487                req.on("data", (chunk) => (body += chunk));
488                req.on("end", () => {
489                    res.writeHead(201, { "x-request-id": "req_1" });
490                    res.write(`${req.method} ${req.url} ${req.headers.authorization} `);
491                    setTimeout(() => res.end(body), 10);
492                });
493            });
494            server.unref();
495            await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
496            return `http://127.0.0.1:${server.address().port}`;
497        }
498    "#)]
499    extern "C" {
500        #[wasm_bindgen(js_name = startServer)]
501        async fn start_server() -> JsValue;
502    }
503
504    async fn server_url() -> String {
505        start_server().await.as_string().unwrap()
506    }
507
508    #[wasm_bindgen_test]
509    async fn sends_requests_and_streams_bodies() {
510        let url = server_url().await;
511        let response = ReqwestTransport::new()
512            .send(
513                HttpRequest::post(format!("{url}/v1/items"), "{\"a\":1}")
514                    .header("authorization", "Bearer k")
515                    .timeout(Some(Duration::from_secs(5))),
516            )
517            .await
518            .unwrap();
519        assert_eq!(response.status, 201);
520        assert_eq!(response.headers.get("X-Request-Id"), Some("req_1"));
521        assert_eq!(
522            response.text().await.unwrap(),
523            "POST /v1/items Bearer k {\"a\":1}"
524        );
525    }
526
527    #[wasm_bindgen_test]
528    async fn failures_are_classified() {
529        let url = server_url().await;
530        let slow = ReqwestTransport::new()
531            .send(HttpRequest::get(format!("{url}/slow")).timeout(Some(Duration::from_millis(50))))
532            .await
533            .unwrap_err();
534        assert_eq!(slow.kind(), TransportErrorKind::Timeout);
535
536        let unbuildable = ReqwestTransport::new()
537            .send(HttpRequest::get("not a url"))
538            .await
539            .unwrap_err();
540        assert_eq!(unbuildable.kind(), TransportErrorKind::InvalidRequest);
541    }
542}