1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3
4pub mod error;
46pub mod middleware;
48
49use std::sync::Arc;
50use std::time::Duration;
51
52use middleware::{ClientBuilder as ChainBuilder, ClientWithMiddleware, Middleware};
53use serde::Serialize;
54#[cfg(feature = "json")]
55use serde::de::DeserializeOwned;
56
57pub use error::FetchError;
58pub use reqwest::Response;
59
60#[derive(Clone, Debug)]
62pub struct Client {
63 inner: ClientWithMiddleware,
64 pub(crate) base_url: Option<String>,
65}
66
67pub struct ClientBuilder {
69 reqwest_builder: reqwest::ClientBuilder,
70 base_url: Option<String>,
71 #[cfg(feature = "retry")]
72 retries: u32,
73 #[cfg(feature = "retry")]
74 initial_backoff: Duration,
75 #[cfg(feature = "retry")]
76 max_backoff: Duration,
77 default_headers: reqwest::header::HeaderMap,
78 user_middleware: Vec<Arc<dyn Middleware>>,
79 #[cfg(feature = "circuit-breaker")]
80 breaker: Option<breaker::CircuitBreaker>,
81}
82
83impl Default for ClientBuilder {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89impl ClientBuilder {
90 pub fn new() -> Self {
92 Self {
93 reqwest_builder: reqwest::Client::builder().timeout(Duration::from_secs(30)),
94 base_url: None,
95 #[cfg(feature = "retry")]
96 retries: 3,
97 #[cfg(feature = "retry")]
98 initial_backoff: Duration::from_millis(500),
99 #[cfg(feature = "retry")]
100 max_backoff: Duration::from_secs(30),
101 default_headers: reqwest::header::HeaderMap::new(),
102 user_middleware: Vec::new(),
103 #[cfg(feature = "circuit-breaker")]
104 breaker: None,
105 }
106 }
107
108 pub fn base_url(mut self, url: impl Into<String>) -> Self {
110 self.base_url = Some(url.into());
111 self
112 }
113
114 pub fn timeout(mut self, timeout: Duration) -> Self {
116 self.reqwest_builder = self.reqwest_builder.timeout(timeout);
117 self
118 }
119
120 #[cfg(feature = "retry")]
127 pub fn retries(mut self, retries: u32) -> Self {
128 self.retries = retries;
129 self
130 }
131
132 #[cfg(feature = "retry")]
134 pub fn retry_bounds(mut self, initial: Duration, max: Duration) -> Self {
135 self.initial_backoff = initial;
136 self.max_backoff = max;
137 self
138 }
139
140 pub fn with_middleware(mut self, middleware: impl Middleware) -> Self {
146 self.user_middleware.push(Arc::new(middleware));
147 self
148 }
149
150 #[allow(clippy::expect_used)]
159 pub fn default_header(mut self, key: &'static str, value: &'static str) -> Self {
160 let name = http::header::HeaderName::from_static(key);
161 let val = http::HeaderValue::from_str(value).expect("invalid header value");
162 self.default_headers.insert(name, val);
163 self
164 }
165
166 pub fn default_headers(mut self, headers: reqwest::header::HeaderMap) -> Self {
168 for (key, value) in headers {
169 if let Some(key) = key {
170 self.default_headers.insert(key, value);
171 }
172 }
173 self
174 }
175
176 pub fn user_agent(mut self, agent: &'static str) -> Self {
178 self.reqwest_builder = self.reqwest_builder.user_agent(agent);
179 self
180 }
181
182 pub fn reqwest_builder(mut self, builder: reqwest::ClientBuilder) -> Self {
184 self.reqwest_builder = builder;
185 self
186 }
187
188 #[cfg(feature = "circuit-breaker")]
190 pub fn with_breaker(mut self, breaker: breaker::CircuitBreaker) -> Self {
191 self.breaker = Some(breaker);
192 self
193 }
194
195 #[allow(clippy::expect_used)]
203 pub fn build(self) -> Client {
204 self.try_build()
205 .expect("failed to build reqwest client — this is a process-startup defect")
206 }
207
208 pub fn try_build(self) -> Result<Client, FetchError> {
210 let reqwest_client = self
211 .reqwest_builder
212 .default_headers(self.default_headers)
216 .build()
217 .map_err(|e| FetchError::BuildError(e.to_string()))?;
218
219 let mut chain = ChainBuilder::new(reqwest_client);
225 for middleware in self.user_middleware {
226 chain = chain.with_middleware(middleware);
227 }
228
229 #[cfg(feature = "retry")]
230 let chain = {
231 use loop_retry::RetryConfig;
232 let config = RetryConfig {
233 max_retries: self.retries,
234 initial_delay: self.initial_backoff,
235 max_delay: self.max_backoff,
236 ..RetryConfig::default()
237 };
238 chain.with(middleware::RetryMiddleware::new(config))
239 };
240
241 #[cfg(feature = "circuit-breaker")]
242 let chain = match self.breaker {
243 Some(breaker) => chain.with(middleware::CircuitBreakerMiddleware::new(breaker)),
244 None => chain,
245 };
246
247 Ok(Client {
248 inner: chain.build(),
249 base_url: self.base_url,
250 })
251 }
252}
253
254impl Client {
255 pub fn new() -> Self {
257 ClientBuilder::new().build()
258 }
259
260 pub fn builder() -> ClientBuilder {
262 ClientBuilder::new()
263 }
264
265 pub fn from_parts(inner: ClientWithMiddleware, base_url: Option<String>) -> Self {
273 Self { inner, base_url }
274 }
275
276 pub fn with_middleware(self, middleware: impl Middleware) -> Self {
283 Self {
284 inner: self.inner.with_middleware(Arc::new(middleware)),
285 base_url: self.base_url,
286 }
287 }
288
289 pub(crate) fn resolve_url(&self, url: &str) -> String {
291 match &self.base_url {
292 Some(base) => {
293 let base = base.trim_end_matches('/');
294 let path = url.trim_start_matches('/');
295 format!("{base}/{path}")
296 }
297 None => url.to_string(),
298 }
299 }
300
301 pub fn get(&self, url: impl Into<String>) -> RequestBuilder<'_> {
303 let url = self.resolve_url(&url.into());
304 RequestBuilder {
305 client: self,
306 builder: self.inner.get(&url),
307 }
308 }
309
310 pub fn post(&self, url: impl Into<String>) -> RequestBuilder<'_> {
312 let url = self.resolve_url(&url.into());
313 RequestBuilder {
314 client: self,
315 builder: self.inner.post(&url),
316 }
317 }
318
319 pub fn put(&self, url: impl Into<String>) -> RequestBuilder<'_> {
321 let url = self.resolve_url(&url.into());
322 RequestBuilder {
323 client: self,
324 builder: self.inner.put(&url),
325 }
326 }
327
328 pub fn delete(&self, url: impl Into<String>) -> RequestBuilder<'_> {
330 let url = self.resolve_url(&url.into());
331 RequestBuilder {
332 client: self,
333 builder: self.inner.delete(&url),
334 }
335 }
336
337 #[cfg(feature = "json")]
340 pub async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, FetchError> {
341 let url = self.resolve_url(url);
342 let response = self.inner.get(&url).send().await?;
343 let response = Self::check_status(response).await?;
344 Ok(response.json().await?)
345 }
346
347 #[cfg(feature = "json")]
350 pub async fn post_json<B: Serialize, T: DeserializeOwned>(
351 &self,
352 url: &str,
353 body: &B,
354 ) -> Result<T, FetchError> {
355 let url = self.resolve_url(url);
356 let response = self.inner.post(&url).json(body).send().await?;
357 let response = Self::check_status(response).await?;
358 Ok(response.json().await?)
359 }
360
361 #[cfg(feature = "json")]
364 pub async fn fetch_with_fallback<T: DeserializeOwned>(
365 &self,
366 primary_url: &str,
367 fallback_url: &str,
368 ) -> Result<T, FetchError> {
369 match self.get_json(primary_url).await {
370 Ok(value) => Ok(value),
371 Err(_) => self.get_json(fallback_url).await,
372 }
373 }
374
375 pub fn inner(&self) -> &ClientWithMiddleware {
381 &self.inner
382 }
383
384 async fn check_status(response: Response) -> Result<Response, FetchError> {
385 let status = response.status();
386 if status.is_success() {
387 Ok(response)
388 } else {
389 let code = status.as_u16();
390 let body = response.text().await.unwrap_or_default();
391 Err(FetchError::StatusCode { status: code, body })
392 }
393 }
394}
395
396impl Default for Client {
397 fn default() -> Self {
398 Self::new()
399 }
400}
401
402pub struct RequestBuilder<'a> {
405 #[allow(dead_code)]
406 client: &'a Client,
407 builder: middleware::RequestBuilder,
408}
409
410impl<'a> RequestBuilder<'a> {
411 pub fn with_extension<T: Clone + Send + Sync + 'static>(mut self, value: T) -> Self {
415 self.builder = self.builder.with_extension(value);
416 self
417 }
418
419 pub fn header<K, V>(mut self, key: K, value: V) -> Self
421 where
422 http::header::HeaderName: TryFrom<K>,
423 <http::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
424 http::HeaderValue: TryFrom<V>,
425 <http::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
426 {
427 self.builder = self.builder.header(key, value);
428 self
429 }
430
431 pub fn headers(mut self, headers: reqwest::header::HeaderMap) -> Self {
433 self.builder = self.builder.headers(headers);
434 self
435 }
436
437 pub fn query<T: Serialize + ?Sized>(mut self, params: &T) -> Self {
439 self.builder = self.builder.query(params);
440 self
441 }
442
443 pub fn timeout(mut self, timeout: Duration) -> Self {
445 self.builder = self.builder.timeout(timeout);
446 self
447 }
448
449 #[cfg(feature = "json")]
451 pub fn json<T: Serialize + ?Sized>(mut self, body: &T) -> Self {
452 self.builder = self.builder.json(body);
453 self
454 }
455
456 pub fn bearer_auth(mut self, token: impl std::fmt::Display) -> Self {
458 self.builder = self.builder.bearer_auth(token);
459 self
460 }
461
462 pub fn basic_auth(
464 mut self,
465 username: impl std::fmt::Display,
466 password: Option<impl std::fmt::Display>,
467 ) -> Self {
468 self.builder = self.builder.basic_auth(username, password);
469 self
470 }
471
472 pub fn body(mut self, body: impl Into<reqwest::Body>) -> Self {
474 self.builder = self.builder.body(body);
475 self
476 }
477
478 pub fn form<T: Serialize + ?Sized>(mut self, form: &T) -> Self {
480 self.builder = self.builder.form(form);
481 self
482 }
483
484 #[cfg(feature = "multipart")]
486 pub fn multipart(mut self, form: reqwest::multipart::Form) -> Self {
487 self.builder = self.builder.multipart(form);
488 self
489 }
490
491 pub async fn send(self) -> Result<Response, FetchError> {
493 let response = self.builder.send().await?;
494 Client::check_status(response).await
495 }
496
497 #[cfg(feature = "json")]
500 pub async fn json_response<T: DeserializeOwned>(self) -> Result<T, FetchError> {
501 let response = self.send().await?;
502 Ok(response.json().await?)
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*;
510 use std::time::Duration;
511
512 #[test]
513 #[cfg(feature = "retry")]
514 fn client_builder_default_timeout() {
515 let builder = ClientBuilder::new();
516 assert_eq!(builder.retries, 3);
517 }
518
519 #[test]
520 #[cfg(feature = "retry")]
521 fn client_builder_default_retries() {
522 let builder = ClientBuilder::new();
523 assert_eq!(builder.retries, 3);
524 }
525
526 #[test]
527 fn client_builder_default_no_base_url() {
528 let builder = ClientBuilder::new();
529 assert!(builder.base_url.is_none());
530 }
531
532 #[test]
533 fn client_builder_custom_timeout() {
534 let builder = ClientBuilder::new().timeout(Duration::from_secs(10));
535 let client = builder.build();
536 let _ = client.inner();
537 }
538
539 #[test]
540 #[cfg(feature = "retry")]
541 fn client_builder_custom_retries() {
542 let builder = ClientBuilder::new().retries(5);
543 assert_eq!(builder.retries, 5);
544 }
545
546 #[test]
547 fn client_builder_custom_base_url() {
548 let builder = ClientBuilder::new().base_url("https://api.example.com");
549 let client = builder.build();
550 assert!(client.base_url.is_some());
551 assert_eq!(client.base_url.as_deref(), Some("https://api.example.com"));
552 }
553
554 #[test]
555 #[cfg(feature = "retry")]
556 fn client_builder_chaining() {
557 let client = ClientBuilder::new()
558 .base_url("https://api.example.com")
559 .timeout(Duration::from_secs(60))
560 .retries(10)
561 .build();
562 assert_eq!(client.base_url.as_deref(), Some("https://api.example.com"));
563 }
564
565 #[test]
566 fn fetch_error_network_display() {
567 let err = FetchError::Network("connection refused".into());
568 let msg = err.to_string();
569 assert!(msg.contains("connection refused"));
570 }
571
572 #[test]
573 fn fetch_error_timeout_display() {
574 let err = FetchError::Timeout(Duration::from_secs(5));
575 let msg = err.to_string();
576 assert!(msg.contains("5s"));
577 }
578
579 #[test]
580 fn fetch_error_status_code_display() {
581 let err = FetchError::StatusCode {
582 status: 404,
583 body: "not found".into(),
584 };
585 let msg = err.to_string();
586 assert!(msg.contains("404"));
587 assert!(msg.contains("not found"));
588 }
589
590 #[test]
591 fn fetch_error_serialization_display() {
592 let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
593 let err = FetchError::Serialization(json_err);
594 let msg = err.to_string();
595 assert!(msg.contains("serialization error"));
596 }
597
598 #[test]
599 fn fetch_error_circuit_open_display() {
600 let err = FetchError::CircuitOpen;
601 let msg = err.to_string();
602 assert!(msg.contains("circuit breaker"));
603 }
604
605 #[test]
606 fn fetch_error_middleware_display() {
607 let err = FetchError::Middleware("tower error".into());
608 let msg = err.to_string();
609 assert!(msg.contains("tower error"));
610 }
611
612 #[test]
613 fn url_resolution_base_with_relative_path() {
614 let client = Client::new();
615 let client = Client {
616 base_url: Some("https://api.example.com".into()),
617 ..client
618 };
619 let resolved = client.resolve_url("/users/1");
620 assert_eq!(resolved, "https://api.example.com/users/1");
621 }
622
623 #[test]
624 fn url_resolution_no_base_url() {
625 let client = Client::new();
626 let resolved = client.resolve_url("/users/1");
627 assert_eq!(resolved, "/users/1");
628 }
629
630 #[test]
631 fn url_resolution_base_trailing_slash() {
632 let client = Client {
633 base_url: Some("https://api.example.com/".into()),
634 inner: Client::new().inner,
635 };
636 let resolved = client.resolve_url("/users/1");
637 assert_eq!(resolved, "https://api.example.com/users/1");
638 }
639
640 #[test]
641 fn url_resolution_path_no_leading_slash() {
642 let client = Client {
643 base_url: Some("https://api.example.com".into()),
644 inner: Client::new().inner,
645 };
646 let resolved = client.resolve_url("users/1");
647 assert_eq!(resolved, "https://api.example.com/users/1");
648 }
649
650 #[test]
651 fn url_resolution_empty_base_trailing_slash() {
652 let client = Client {
653 base_url: Some("https://api.example.com/".into()),
654 inner: Client::new().inner,
655 };
656 let resolved = client.resolve_url("users/1");
657 assert_eq!(resolved, "https://api.example.com/users/1");
658 }
659
660 #[test]
661 fn client_default_trait() {
662 let client = Client::default();
663 assert!(client.base_url.is_none());
664 }
665
666 #[test]
667 #[cfg(feature = "retry")]
668 fn client_builder_default_trait() {
669 let builder = ClientBuilder::default();
670 assert_eq!(builder.retries, 3);
671 }
672
673 #[test]
676 #[cfg(feature = "retry")]
677 fn client_builder_new_creates_valid_builder() {
678 let builder = ClientBuilder::new();
679 assert_eq!(builder.retries, 3);
680 assert!(builder.base_url.is_none());
681 let client = builder.build();
682 assert!(client.base_url.is_none());
683 }
684
685 #[test]
686 fn client_builder_base_url_strips_trailing_slash_on_build() {
687 let client = ClientBuilder::new()
688 .base_url("https://api.example.com/")
689 .build();
690 let resolved = client.resolve_url("/users/1");
691 assert_eq!(resolved, "https://api.example.com/users/1");
692 }
693
694 #[test]
695 fn client_builder_timeout_zero() {
696 let client = ClientBuilder::new().timeout(Duration::from_secs(0)).build();
697 let _ = client.inner();
698 }
699
700 #[test]
701 fn client_builder_timeout_large() {
702 let client = ClientBuilder::new()
703 .timeout(Duration::from_secs(3600))
704 .build();
705 let _ = client.inner();
706 }
707
708 #[test]
709 #[cfg(feature = "retry")]
710 fn client_builder_retries_zero() {
711 let builder = ClientBuilder::new().retries(0);
712 assert_eq!(builder.retries, 0);
713 }
714
715 #[test]
716 #[cfg(feature = "retry")]
717 fn client_builder_retries_large() {
718 let builder = ClientBuilder::new().retries(1000);
719 assert_eq!(builder.retries, 1000);
720 }
721
722 #[test]
723 #[cfg(feature = "retry")]
724 fn client_builder_overwrite_retries() {
725 let builder = ClientBuilder::new().retries(5).retries(10);
726 assert_eq!(builder.retries, 10);
727 }
728
729 #[test]
730 fn client_builder_overwrite_timeout() {
731 let client = ClientBuilder::new()
732 .timeout(Duration::from_secs(10))
733 .timeout(Duration::from_secs(60))
734 .build();
735 let _ = client.inner();
736 }
737
738 #[test]
739 #[cfg(feature = "retry")]
740 fn client_builder_full_chain() {
741 let client = ClientBuilder::new()
742 .base_url("https://api.example.com")
743 .timeout(Duration::from_secs(15))
744 .retries(7)
745 .build();
746 assert_eq!(client.base_url.as_deref(), Some("https://api.example.com"));
747 }
748
749 #[test]
752 fn client_new_creates_default() {
753 let client = Client::new();
754 assert!(client.base_url.is_none());
755 let _ = client.inner();
756 }
757
758 #[test]
759 fn client_builder_factory_method() {
760 let client = Client::builder().base_url("https://example.com").build();
761 assert_eq!(client.base_url.as_deref(), Some("https://example.com"));
762 }
763
764 #[test]
765 fn client_builder_factory_default() {
766 let client = Client::builder().build();
767 assert!(client.base_url.is_none());
768 }
769
770 #[test]
773 fn url_resolution_empty_path_with_base() {
774 let client = Client {
775 base_url: Some("https://api.example.com".into()),
776 inner: Client::new().inner,
777 };
778 let resolved = client.resolve_url("");
779 assert_eq!(resolved, "https://api.example.com/");
780 }
781
782 #[test]
783 fn url_resolution_base_multiple_trailing_slashes() {
784 let client = Client {
785 base_url: Some("https://api.example.com///".into()),
786 inner: Client::new().inner,
787 };
788 let resolved = client.resolve_url("/users/1");
789 assert_eq!(resolved, "https://api.example.com/users/1");
790 }
791
792 #[test]
793 fn url_resolution_full_url_with_base() {
794 let client = Client {
795 base_url: Some("https://api.example.com".into()),
796 inner: Client::new().inner,
797 };
798 let resolved = client.resolve_url("users/1");
799 assert_eq!(resolved, "https://api.example.com/users/1");
800 }
801
802 #[test]
803 fn url_resolution_deeply_nested_path() {
804 let client = Client {
805 base_url: Some("https://api.example.com".into()),
806 inner: Client::new().inner,
807 };
808 let resolved = client.resolve_url("/v1/users/42/posts/7/comments");
809 assert_eq!(
810 resolved,
811 "https://api.example.com/v1/users/42/posts/7/comments"
812 );
813 }
814
815 #[test]
818 fn fetch_error_network_empty_message() {
819 let err = FetchError::Network("".into());
820 let msg = err.to_string();
821 assert!(msg.contains("network error"));
822 }
823
824 #[test]
825 fn fetch_error_network_long_message() {
826 let err = FetchError::Network("DNS resolution failed for api.example.com: NXDOMAIN".into());
827 let msg = err.to_string();
828 assert!(msg.contains("DNS resolution failed"));
829 assert!(msg.contains("NXDOMAIN"));
830 }
831
832 #[test]
833 fn fetch_error_timeout_zero_duration() {
834 let err = FetchError::Timeout(Duration::from_secs(0));
835 let msg = err.to_string();
836 assert!(msg.contains("request timed out"));
837 assert!(msg.contains("0ns"));
838 }
839
840 #[test]
841 fn fetch_error_timeout_large_duration() {
842 let err = FetchError::Timeout(Duration::from_secs(3600));
843 let msg = err.to_string();
844 assert!(msg.contains("request timed out"));
845 assert!(msg.contains("3600s"));
846 }
847
848 #[test]
849 fn fetch_error_status_code_various_codes() {
850 for code in [400, 401, 403, 404, 429, 500, 502, 503] {
851 let err = FetchError::StatusCode {
852 status: code,
853 body: format!("error {code}"),
854 };
855 let msg = err.to_string();
856 assert!(msg.contains(&code.to_string()));
857 assert!(msg.contains("HTTP"));
858 }
859 }
860
861 #[test]
862 fn fetch_error_status_code_empty_body() {
863 let err = FetchError::StatusCode {
864 status: 404,
865 body: String::new(),
866 };
867 let msg = err.to_string();
868 assert!(msg.contains("404"));
869 }
870
871 #[test]
872 fn fetch_error_serialization_various_errors() {
873 let json_err = serde_json::from_str::<serde_json::Value>("").unwrap_err();
874 let err = FetchError::Serialization(json_err);
875 let msg = err.to_string();
876 assert!(msg.contains("serialization error"));
877 }
878
879 #[test]
880 fn fetch_error_debug_format() {
881 let errors = vec![
882 FetchError::Network("test".into()),
883 FetchError::Timeout(Duration::from_secs(1)),
884 FetchError::StatusCode {
885 status: 500,
886 body: "err".into(),
887 },
888 FetchError::CircuitOpen,
889 FetchError::Middleware("mw".into()),
890 ];
891 for err in errors {
892 let debug_str = format!("{:?}", err);
893 assert!(!debug_str.is_empty());
894 }
895 }
896
897 #[test]
898 fn fetch_error_is_std_error() {
899 let err = FetchError::Network("test".into());
900 let std_err: &dyn std::error::Error = &err;
901 assert!(std_err.to_string().contains("network error"));
902 }
903
904 #[test]
905 fn fetch_error_from_serde_json_roundtrip() {
906 let json_err = serde_json::from_str::<serde_json::Value>("invalid json!!").unwrap_err();
907 let msg = json_err.to_string();
908 let fetch_err = FetchError::from(json_err);
909 let fetch_msg = fetch_err.to_string();
910 assert!(fetch_msg.contains("serialization error"));
911 assert!(fetch_msg.contains(&msg));
912 }
913
914 #[test]
917 fn client_clone_preserves_base_url() {
918 let client = Client::builder()
919 .base_url("https://api.example.com")
920 .build();
921 let cloned = client.clone();
922 assert_eq!(cloned.base_url.as_deref(), Some("https://api.example.com"));
923 }
924
925 #[test]
926 fn client_clone_independence() {
927 let client1 = Client::builder().base_url("https://first.com").build();
928 let client2 = client1.clone();
929 assert_eq!(client2.base_url.as_deref(), Some("https://first.com"));
930 }
931
932 #[test]
935 fn client_builder_default_header() {
936 let client = ClientBuilder::new()
937 .default_header("x-custom", "value")
938 .build();
939 let _ = client.inner();
940 }
941
942 #[test]
943 fn client_builder_user_agent() {
944 let client = ClientBuilder::new().user_agent("fetch_kit/0.1").build();
945 let _ = client.inner();
946 }
947
948 #[test]
949 #[cfg(feature = "retry")]
950 fn client_builder_retry_bounds() {
951 let builder =
952 ClientBuilder::new().retry_bounds(Duration::from_millis(100), Duration::from_secs(5));
953 assert_eq!(builder.retries, 3);
954 let client = builder.build();
955 let _ = client.inner();
956 }
957
958 #[test]
959 fn client_builder_try_build_success() {
960 let result = ClientBuilder::new().try_build();
961 assert!(result.is_ok());
962 }
963
964 #[test]
965 fn fetch_error_build_error_display() {
966 let err = FetchError::BuildError("tls error".into());
967 let msg = err.to_string();
968 assert!(msg.contains("build error"));
969 assert!(msg.contains("tls error"));
970 }
971
972 #[test]
973 fn fetch_error_header_error_display() {
974 let err = FetchError::HeaderError("invalid header".into());
975 let msg = err.to_string();
976 assert!(msg.contains("header error"));
977 assert!(msg.contains("invalid header"));
978 }
979
980 #[test]
981 fn client_get_returns_request_builder() {
982 let client = Client::new();
983 let builder = client.get("https://example.com");
984 let _ = builder;
985 }
986
987 #[test]
988 fn client_post_returns_request_builder() {
989 let client = Client::new();
990 let builder = client.post("https://example.com");
991 let _ = builder;
992 }
993
994 #[test]
995 fn client_put_returns_request_builder() {
996 let client = Client::new();
997 let builder = client.put("https://example.com");
998 let _ = builder;
999 }
1000
1001 #[test]
1002 fn client_delete_returns_request_builder() {
1003 let client = Client::new();
1004 let builder = client.delete("https://example.com");
1005 let _ = builder;
1006 }
1007
1008 #[test]
1009 fn request_builder_header_chain() {
1010 let client = Client::new();
1011 let builder = client
1012 .get("https://example.com")
1013 .header("X-Test", "value")
1014 .bearer_auth("token");
1015 let _ = builder;
1016 }
1017
1018 #[test]
1019 fn request_builder_query_chain() {
1020 let client = Client::new();
1021 let builder = client.get("https://example.com").query(&[("key", "value")]);
1022 let _ = builder;
1023 }
1024
1025 #[test]
1026 fn request_builder_timeout_chain() {
1027 let client = Client::new();
1028 let builder = client
1029 .get("https://example.com")
1030 .timeout(Duration::from_secs(5));
1031 let _ = builder;
1032 }
1033
1034 #[test]
1035 fn fetch_error_from_reqwest_timeout_uses_zero_duration() {
1036 let err = FetchError::Timeout(Duration::ZERO);
1037 assert!(err.to_string().contains("request timed out"));
1038 assert!(err.to_string().contains("0"));
1039 }
1040}