1use std::fmt;
10use std::time::Duration;
11
12use bytes::Bytes;
13use http::{HeaderMap, Method};
14use polyoxide_rt::BoxFuture;
15use polyoxide_venue::{Class, Classify};
16use thiserror::Error;
17use url::Url;
18
19use crate::response::Response;
20
21#[derive(Debug, Clone)]
26#[non_exhaustive]
27pub struct HttpRequest {
28 pub method: Method,
30 pub url: Url,
33 pub headers: HeaderMap,
35 pub body: Option<Bytes>,
37 pub timeout: Duration,
40}
41
42pub trait HttpTransport: Send + Sync + fmt::Debug + 'static {
47 fn send(&self, request: HttpRequest) -> BoxFuture<Result<Response, TransportError>>;
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum TransportKind {
64 Connect,
66 Timeout,
68 Body,
70 Request,
72 Other,
74}
75
76impl fmt::Display for TransportKind {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 f.write_str(match self {
79 Self::Connect => "connect",
80 Self::Timeout => "timeout",
81 Self::Body => "body",
82 Self::Request => "request",
83 Self::Other => "other",
84 })
85 }
86}
87
88#[derive(Debug, Error)]
96#[non_exhaustive]
97#[error("{kind}: {source}")]
98pub struct TransportError {
99 kind: TransportKind,
100 source: Box<dyn std::error::Error + Send + Sync>,
101}
102
103impl TransportError {
104 pub fn new(
106 kind: TransportKind,
107 source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
108 ) -> Self {
109 Self {
110 kind,
111 source: source.into(),
112 }
113 }
114
115 pub fn kind(&self) -> TransportKind {
117 self.kind
118 }
119}
120
121impl Classify for TransportError {
122 fn class(&self) -> Class {
123 match self.kind {
124 TransportKind::Request => Class::InvalidRequest,
125 TransportKind::Connect
126 | TransportKind::Timeout
127 | TransportKind::Body
128 | TransportKind::Other => Class::Network,
129 }
130 }
131}
132
133#[cfg(not(target_arch = "wasm32"))]
138#[derive(Debug, Clone)]
139pub struct ReqwestTransport {
140 client: reqwest::Client,
141}
142
143#[cfg(not(target_arch = "wasm32"))]
144impl ReqwestTransport {
145 pub fn new(client: reqwest::Client) -> Self {
147 Self { client }
148 }
149
150 pub(crate) fn from_settings(
155 timeout: Duration,
156 pool_size: usize,
157 gzip: Option<bool>,
158 ) -> Result<Self, TransportError> {
159 let mut builder = reqwest::Client::builder();
160 if let Some(enabled) = gzip {
161 builder = builder.gzip(enabled);
162 }
163 let client = builder
164 .timeout(timeout)
165 .connect_timeout(Duration::from_secs(10))
166 .redirect(reqwest::redirect::Policy::none())
167 .pool_max_idle_per_host(pool_size)
168 .build()
169 .map_err(map_error)?;
170 Ok(Self::new(client))
171 }
172}
173
174#[cfg(not(target_arch = "wasm32"))]
177pub(crate) fn default_transport(
178 timeout: Duration,
179 pool_size: usize,
180 gzip: Option<bool>,
181) -> Result<std::sync::Arc<dyn HttpTransport>, TransportError> {
182 Ok(std::sync::Arc::new(ReqwestTransport::from_settings(
183 timeout, pool_size, gzip,
184 )?))
185}
186
187#[cfg(target_arch = "wasm32")]
191pub(crate) fn default_transport(
192 _timeout: Duration,
193 _pool_size: usize,
194 _gzip: Option<bool>,
195) -> Result<std::sync::Arc<dyn HttpTransport>, TransportError> {
196 Ok(std::sync::Arc::new(crate::FetchTransport::new()))
197}
198
199#[cfg(not(target_arch = "wasm32"))]
200impl HttpTransport for ReqwestTransport {
201 fn send(&self, request: HttpRequest) -> BoxFuture<Result<Response, TransportError>> {
202 let mut builder = self
203 .client
204 .request(request.method, request.url)
205 .headers(request.headers)
206 .timeout(request.timeout);
207 if let Some(body) = request.body {
208 builder = builder.body(body);
209 }
210 Box::pin(async move {
211 let response = builder.send().await.map_err(map_error)?;
212 let status = response.status();
213 let headers = response.headers().clone();
214 let url = response.url().clone();
215 Ok(Response::new(
216 status,
217 headers,
218 url,
219 Box::pin(async move { response.bytes().await.map_err(map_error) }),
220 ))
221 })
222 }
223}
224
225#[cfg(not(target_arch = "wasm32"))]
229fn map_error(err: reqwest::Error) -> TransportError {
230 let flags = Flags {
231 timeout: err.is_timeout(),
232 connect: err.is_connect(),
233 body: err.is_body() || err.is_decode(),
234 builder: err.is_builder(),
235 };
236 TransportError::new(kind_of(flags, &err), err)
237}
238
239#[cfg(not(target_arch = "wasm32"))]
241#[derive(Clone, Copy)]
242struct Flags {
243 timeout: bool,
244 connect: bool,
245 body: bool,
247 builder: bool,
248}
249
250#[cfg(not(target_arch = "wasm32"))]
255fn kind_of(flags: Flags, err: &(dyn std::error::Error + 'static)) -> TransportKind {
256 if flags.timeout {
257 TransportKind::Timeout
258 } else if flags.connect {
259 TransportKind::Connect
260 } else if flags.body {
261 TransportKind::Body
262 } else if failed_in_transit(err) {
263 TransportKind::Other
264 } else if flags.builder {
265 TransportKind::Request
266 } else {
267 TransportKind::Other
268 }
269}
270
271#[cfg(not(target_arch = "wasm32"))]
274fn failed_in_transit(err: &(dyn std::error::Error + 'static)) -> bool {
275 let mut source = err.source();
276 while let Some(inner) = source {
277 if inner.is::<std::io::Error>()
278 || inner
279 .downcast_ref::<reqwest::Error>()
280 .is_some_and(reqwest::Error::is_body)
281 {
282 return true;
283 }
284 source = inner.source();
285 }
286 false
287}
288
289#[cfg(not(target_arch = "wasm32"))]
290#[cfg(test)]
291mod tests {
292 use std::io::{Read, Write};
293 use std::net::TcpListener;
294
295 use http::{HeaderValue, StatusCode};
296
297 use super::*;
298 use crate::error::ApiError;
299
300 #[test]
301 fn a_kind_displays_in_lower_case() {
302 for (kind, text) in [
303 (TransportKind::Connect, "connect"),
304 (TransportKind::Timeout, "timeout"),
305 (TransportKind::Body, "body"),
306 (TransportKind::Request, "request"),
307 (TransportKind::Other, "other"),
308 ] {
309 assert_eq!(kind.to_string(), text);
310 }
311 }
312
313 #[test]
314 fn an_error_displays_its_kind_then_its_source() {
315 let err = TransportError::new(TransportKind::Timeout, "no answer");
316 assert_eq!(err.kind(), TransportKind::Timeout);
317 assert_eq!(err.to_string(), "timeout: no answer");
318 assert_eq!(
319 std::error::Error::source(&err).map(ToString::to_string),
320 Some("no answer".to_owned())
321 );
322 assert_eq!(
323 ApiError::from(err).to_string(),
324 "Transport error: timeout: no answer"
325 );
326 }
327
328 #[test]
329 fn only_a_request_that_could_not_be_built_is_not_a_network_failure() {
330 for kind in [
331 TransportKind::Connect,
332 TransportKind::Timeout,
333 TransportKind::Body,
334 TransportKind::Other,
335 ] {
336 let err = TransportError::new(kind, "x");
337 assert_eq!(err.class(), Class::Network, "{kind}");
338 assert!(err.is_retriable(), "{kind}");
339 assert!(err.is_fault(), "{kind}");
340 }
341 let err = TransportError::new(TransportKind::Request, "x");
342 assert_eq!(err.class(), Class::InvalidRequest);
343 assert!(!err.is_retriable());
344 }
345
346 #[derive(Debug, Error)]
349 #[error("wrapped")]
350 struct Wrapped(#[source] std::io::Error);
351
352 #[test]
353 fn an_io_error_in_the_source_chain_is_never_a_request_error() {
354 let err = Wrapped(std::io::Error::from(std::io::ErrorKind::ConnectionReset));
355 for (flags, expected) in [
356 (
357 Flags {
358 timeout: false,
359 connect: false,
360 body: false,
361 builder: true,
362 },
363 TransportKind::Other,
364 ),
365 (
366 Flags {
367 timeout: false,
368 connect: false,
369 body: false,
370 builder: false,
371 },
372 TransportKind::Other,
373 ),
374 (
375 Flags {
376 timeout: true,
377 connect: false,
378 body: false,
379 builder: true,
380 },
381 TransportKind::Timeout,
382 ),
383 (
384 Flags {
385 timeout: false,
386 connect: true,
387 body: false,
388 builder: true,
389 },
390 TransportKind::Connect,
391 ),
392 (
393 Flags {
394 timeout: false,
395 connect: false,
396 body: true,
397 builder: true,
398 },
399 TransportKind::Body,
400 ),
401 ] {
402 let kind = kind_of(flags, &err);
403 assert_eq!(kind, expected);
404 assert_eq!(TransportError::new(kind, "x").class(), Class::Network);
405 }
406 let plain = TransportError::new(
408 kind_of(
409 Flags {
410 timeout: false,
411 connect: false,
412 body: false,
413 builder: true,
414 },
415 &fmt::Error,
416 ),
417 "x",
418 );
419 assert_eq!(plain.kind(), TransportKind::Request);
420 }
421
422 fn request(url: &str) -> HttpRequest {
423 HttpRequest {
424 method: Method::GET,
425 url: Url::parse(url).unwrap(),
426 headers: HeaderMap::new(),
427 body: None,
428 timeout: Duration::from_secs(5),
429 }
430 }
431
432 fn transport() -> ReqwestTransport {
435 ReqwestTransport::new(reqwest::Client::builder().no_proxy().build().unwrap())
436 }
437
438 #[tokio::test]
439 async fn the_head_comes_back_whole_and_the_body_is_read_on_demand() {
440 let mut server = mockito::Server::new_async().await;
441 let mock = server
442 .mock("GET", "/v1/rows")
443 .match_query(mockito::Matcher::UrlEncoded("limit".into(), "2".into()))
444 .match_header("x-trace", "t-1")
445 .with_status(201)
446 .with_header("x-served-by", "mock")
447 .with_body("rows")
448 .create_async()
449 .await;
450 let mut sent = request(&format!("{}/v1/rows?limit=2", server.url()));
451 sent.headers
452 .insert("x-trace", HeaderValue::from_static("t-1"));
453
454 let response = transport().send(sent.clone()).await.unwrap();
455
456 assert_eq!(response.status(), StatusCode::CREATED);
457 assert_eq!(
458 response
459 .headers()
460 .get("x-served-by")
461 .and_then(|value| value.to_str().ok()),
462 Some("mock")
463 );
464 assert_eq!(response.url(), &sent.url);
465 assert_eq!(response.text().await.unwrap(), "rows");
466 mock.assert_async().await;
467 }
468
469 #[tokio::test]
470 async fn the_method_and_the_body_are_sent() {
471 let mut server = mockito::Server::new_async().await;
472 let mock = server
473 .mock("POST", "/v1/order")
474 .match_body(r#"{"size":1}"#)
475 .with_status(200)
476 .with_body("{}")
477 .create_async()
478 .await;
479 let mut sent = request(&format!("{}/v1/order", server.url()));
480 sent.method = Method::POST;
481 sent.body = Some(Bytes::from_static(br#"{"size":1}"#));
482
483 let response = transport().send(sent).await.unwrap();
484
485 assert_eq!(response.status(), StatusCode::OK);
486 mock.assert_async().await;
487 }
488
489 #[tokio::test]
490 async fn an_error_status_is_a_response_and_never_a_transport_error() {
491 let mut server = mockito::Server::new_async().await;
492 let _down = server
493 .mock("GET", "/v1/rows")
494 .with_status(503)
495 .with_body("down")
496 .create_async()
497 .await;
498
499 let response = transport()
500 .send(request(&format!("{}/v1/rows", server.url())))
501 .await
502 .unwrap();
503
504 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
505 assert_eq!(response.text().await.unwrap(), "down");
506 }
507
508 #[tokio::test]
509 async fn a_refused_connection_is_a_connect_failure() {
510 let port = TcpListener::bind("127.0.0.1:0")
512 .unwrap()
513 .local_addr()
514 .unwrap()
515 .port();
516
517 let err = transport()
518 .send(request(&format!("http://127.0.0.1:{port}/")))
519 .await
520 .unwrap_err();
521
522 assert_eq!(err.kind(), TransportKind::Connect, "{err:?}");
523 assert_eq!(err.class(), Class::Network);
524 }
525
526 #[tokio::test]
527 async fn a_host_that_never_answers_is_a_timeout() {
528 let silent = TcpListener::bind("127.0.0.1:0").unwrap();
530 let mut sent = request(&format!("http://{}/", silent.local_addr().unwrap()));
531 sent.timeout = Duration::from_millis(200);
532
533 let err = transport().send(sent).await.unwrap_err();
534
535 assert_eq!(err.kind(), TransportKind::Timeout, "{err:?}");
536 assert_eq!(err.class(), Class::Network);
537 }
538
539 fn short_body_server(hold: Duration) -> (String, std::thread::JoinHandle<()>) {
542 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
543 let url = format!("http://{}/", listener.local_addr().unwrap());
544 let server = std::thread::spawn(move || {
545 let (mut stream, _) = listener.accept().unwrap();
546 let _ = stream.read(&mut [0; 1024]);
547 let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n{\"a\":");
548 std::thread::sleep(hold);
549 });
550 (url, server)
551 }
552
553 #[tokio::test]
554 async fn a_body_that_stops_short_is_a_body_failure() {
555 let (url, server) = short_body_server(Duration::ZERO);
556
557 let response = transport().send(request(&url)).await.unwrap();
558 let err = response.bytes().await.unwrap_err();
559 server.join().unwrap();
560
561 assert!(
562 matches!(&err, ApiError::Transport(e) if e.kind() == TransportKind::Body),
563 "{err:?}"
564 );
565 assert_eq!(err.class(), Class::Network);
566 }
567
568 #[tokio::test]
569 async fn the_timeout_bounds_the_body_as_well_as_the_head() {
570 let (url, server) = short_body_server(Duration::from_millis(1_500));
571 let mut sent = request(&url);
572 sent.timeout = Duration::from_millis(300);
573
574 let response = transport().send(sent).await.unwrap();
577 let err = response.bytes().await.unwrap_err();
578 server.join().unwrap();
579
580 assert!(
581 matches!(&err, ApiError::Transport(e) if e.kind() == TransportKind::Timeout),
582 "{err:?}"
583 );
584 }
585
586 #[tokio::test]
587 async fn a_scheme_the_client_refuses_is_a_request_failure() {
588 let err = transport()
589 .send(request("ftp://127.0.0.1/file"))
590 .await
591 .unwrap_err();
592
593 assert_eq!(err.kind(), TransportKind::Request, "{err:?}");
594 assert_eq!(err.class(), Class::InvalidRequest);
595 }
596
597 #[tokio::test]
598 async fn the_default_transport_follows_no_redirect() {
599 let mut server = mockito::Server::new_async().await;
600 let _moved = server
601 .mock("GET", "/moved")
602 .with_status(302)
603 .with_header("location", "/elsewhere")
604 .create_async()
605 .await;
606 let elsewhere = server
607 .mock("GET", "/elsewhere")
608 .with_status(200)
609 .expect(0)
610 .create_async()
611 .await;
612 let http = crate::HttpClientBuilder::new(server.url()).build().unwrap();
613
614 let err = http
615 .send(crate::RequestParts::new(Method::GET, "/moved"), &[], None)
616 .await
617 .unwrap_err();
618
619 assert!(
620 matches!(&err, ApiError::Response(r) if r.status == StatusCode::FOUND),
621 "{err:?}"
622 );
623 elsewhere.assert_async().await;
624 }
625}