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
18pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
25
26#[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 #[must_use]
90 pub fn new() -> Self {
91 #[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 #[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 #[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 .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 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 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 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#[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_middleware::Error::Reqwest(inner) => with_cause(inner, TransportErrorKind::Connect),
228 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
238fn 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
256fn 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
264fn 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
277fn 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 #[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 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 #[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 #[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 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 #[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#[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}