1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3
4pub mod error;
12pub mod middleware;
14
15use std::time::Duration;
16
17use reqwest_middleware::{ClientBuilder as ReqwestClientBuilder, ClientWithMiddleware};
18use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
19use serde::{Serialize, de::DeserializeOwned};
20
21pub use error::FetchError;
22pub use reqwest::Response;
23
24#[derive(Clone, Debug)]
26pub struct Client {
27 inner: ClientWithMiddleware,
28 pub(crate) base_url: Option<String>,
29}
30
31pub struct ClientBuilder {
33 reqwest_builder: reqwest::ClientBuilder,
34 retry_policy: ExponentialBackoff,
35 base_url: Option<String>,
36 retries: u32,
37 initial_backoff: Duration,
38 max_backoff: Duration,
39 default_headers: reqwest::header::HeaderMap,
40 #[cfg(feature = "circuit-breaker")]
41 breaker: Option<breaker::CircuitBreaker>,
42}
43
44impl Default for ClientBuilder {
45 fn default() -> Self {
46 Self::new()
47 }
48}
49
50impl ClientBuilder {
51 pub fn new() -> Self {
53 let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
54
55 let reqwest_builder = reqwest::Client::builder().timeout(Duration::from_secs(30));
56
57 Self {
58 reqwest_builder,
59 retry_policy,
60 base_url: None,
61 retries: 3,
62 initial_backoff: Duration::from_millis(500),
63 max_backoff: Duration::from_secs(30),
64 default_headers: reqwest::header::HeaderMap::new(),
65 #[cfg(feature = "circuit-breaker")]
66 breaker: None,
67 }
68 }
69
70 pub fn base_url(mut self, url: impl Into<String>) -> Self {
72 self.base_url = Some(url.into());
73 self
74 }
75
76 pub fn timeout(mut self, timeout: Duration) -> Self {
78 self.reqwest_builder = self.reqwest_builder.timeout(timeout);
79 self
80 }
81
82 pub fn retries(mut self, retries: u32) -> Self {
84 self.retries = retries;
85 self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(retries);
86 self
87 }
88
89 pub fn retry_bounds(mut self, initial: Duration, max: Duration) -> Self {
91 self.initial_backoff = initial;
92 self.max_backoff = max;
93 self.retry_policy = ExponentialBackoff::builder()
94 .retry_bounds(initial, max)
95 .build_with_max_retries(self.retries);
96 self
97 }
98
99 #[allow(clippy::expect_used)]
108 pub fn default_header(mut self, key: &'static str, value: &'static str) -> Self {
109 let name = http::header::HeaderName::from_static(key);
110 let val = http::HeaderValue::from_str(value).expect("invalid header value");
111 self.default_headers.insert(name, val);
112 self
113 }
114
115 pub fn default_headers(mut self, headers: reqwest::header::HeaderMap) -> Self {
117 for (key, value) in headers {
118 if let Some(key) = key {
119 self.default_headers.insert(key, value);
120 }
121 }
122 self
123 }
124
125 pub fn user_agent(mut self, agent: &'static str) -> Self {
127 self.reqwest_builder = self.reqwest_builder.user_agent(agent);
128 self
129 }
130
131 pub fn reqwest_builder(mut self, builder: reqwest::ClientBuilder) -> Self {
133 self.reqwest_builder = builder;
134 self
135 }
136
137 #[cfg(feature = "circuit-breaker")]
139 pub fn with_breaker(mut self, breaker: breaker::CircuitBreaker) -> Self {
140 self.breaker = Some(breaker);
141 self
142 }
143
144 #[allow(clippy::expect_used)]
152 pub fn build(self) -> Client {
153 self.try_build()
154 .expect("failed to build reqwest client — this is a process-startup defect")
155 }
156
157 pub fn try_build(self) -> Result<Client, FetchError> {
159 let reqwest_client = self
160 .reqwest_builder
161 .default_headers(self.default_headers)
165 .build()
166 .map_err(|e| FetchError::BuildError(e.to_string()))?;
167
168 let builder = ReqwestClientBuilder::new(reqwest_client)
169 .with(RetryTransientMiddleware::new_with_policy(self.retry_policy));
170
171 #[cfg(feature = "circuit-breaker")]
172 let builder = match self.breaker {
173 Some(breaker) => builder.with(middleware::CircuitBreakerMiddleware::new(breaker)),
174 None => builder,
175 };
176
177 let inner = builder.build();
178
179 Ok(Client {
180 inner,
181 base_url: self.base_url,
182 })
183 }
184}
185
186impl Client {
187 pub fn new() -> Self {
189 ClientBuilder::new().build()
190 }
191
192 pub fn builder() -> ClientBuilder {
194 ClientBuilder::new()
195 }
196
197 pub fn from_parts(inner: ClientWithMiddleware, base_url: Option<String>) -> Self {
204 Self { inner, base_url }
205 }
206
207 pub(crate) fn resolve_url(&self, url: &str) -> String {
209 match &self.base_url {
210 Some(base) => {
211 let base = base.trim_end_matches('/');
212 let path = url.trim_start_matches('/');
213 format!("{base}/{path}")
214 }
215 None => url.to_string(),
216 }
217 }
218
219 pub fn get(&self, url: impl Into<String>) -> RequestBuilder<'_> {
221 let url = self.resolve_url(&url.into());
222 RequestBuilder {
223 client: self,
224 builder: self.inner.get(&url),
225 }
226 }
227
228 pub fn post(&self, url: impl Into<String>) -> RequestBuilder<'_> {
230 let url = self.resolve_url(&url.into());
231 RequestBuilder {
232 client: self,
233 builder: self.inner.post(&url),
234 }
235 }
236
237 pub fn put(&self, url: impl Into<String>) -> RequestBuilder<'_> {
239 let url = self.resolve_url(&url.into());
240 RequestBuilder {
241 client: self,
242 builder: self.inner.put(&url),
243 }
244 }
245
246 pub fn delete(&self, url: impl Into<String>) -> RequestBuilder<'_> {
248 let url = self.resolve_url(&url.into());
249 RequestBuilder {
250 client: self,
251 builder: self.inner.delete(&url),
252 }
253 }
254
255 pub async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, FetchError> {
257 let url = self.resolve_url(url);
258 let response = self.inner.get(&url).send().await?;
259 let response = Self::check_status(response).await?;
260 Ok(response.json().await?)
261 }
262
263 pub async fn post_json<B: Serialize, T: DeserializeOwned>(
265 &self,
266 url: &str,
267 body: &B,
268 ) -> Result<T, FetchError> {
269 let url = self.resolve_url(url);
270 let response = self.inner.post(&url).json(body).send().await?;
271 let response = Self::check_status(response).await?;
272 Ok(response.json().await?)
273 }
274
275 pub async fn fetch_with_fallback<T: DeserializeOwned>(
277 &self,
278 primary_url: &str,
279 fallback_url: &str,
280 ) -> Result<T, FetchError> {
281 match self.get_json(primary_url).await {
282 Ok(value) => Ok(value),
283 Err(_) => self.get_json(fallback_url).await,
284 }
285 }
286
287 pub fn inner(&self) -> &ClientWithMiddleware {
289 &self.inner
290 }
291
292 async fn check_status(response: Response) -> Result<Response, FetchError> {
293 let status = response.status();
294 if status.is_success() {
295 Ok(response)
296 } else {
297 let code = status.as_u16();
298 let body = response.text().await.unwrap_or_default();
299 Err(FetchError::StatusCode { status: code, body })
300 }
301 }
302}
303
304impl Default for Client {
305 fn default() -> Self {
306 Self::new()
307 }
308}
309
310pub struct RequestBuilder<'a> {
313 #[allow(dead_code)]
314 client: &'a Client,
315 builder: reqwest_middleware::RequestBuilder,
316}
317
318impl<'a> RequestBuilder<'a> {
319 pub fn header<K, V>(mut self, key: K, value: V) -> Self
321 where
322 http::header::HeaderName: TryFrom<K>,
323 <http::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
324 http::HeaderValue: TryFrom<V>,
325 <http::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
326 {
327 self.builder = self.builder.header(key, value);
328 self
329 }
330
331 pub fn headers(mut self, headers: reqwest::header::HeaderMap) -> Self {
333 self.builder = self.builder.headers(headers);
334 self
335 }
336
337 pub fn query<T: Serialize + ?Sized>(mut self, params: &T) -> Self {
339 self.builder = self.builder.query(params);
340 self
341 }
342
343 pub fn timeout(mut self, timeout: Duration) -> Self {
345 self.builder = self.builder.timeout(timeout);
346 self
347 }
348
349 pub fn json<T: Serialize + ?Sized>(mut self, body: &T) -> Self {
351 self.builder = self.builder.json(body);
352 self
353 }
354
355 pub fn bearer_auth(mut self, token: impl std::fmt::Display) -> Self {
357 self.builder = self.builder.bearer_auth(token);
358 self
359 }
360
361 pub fn basic_auth(
363 mut self,
364 username: impl std::fmt::Display,
365 password: Option<impl std::fmt::Display>,
366 ) -> Self {
367 self.builder = self.builder.basic_auth(username, password);
368 self
369 }
370
371 pub fn body(mut self, body: impl Into<reqwest::Body>) -> Self {
373 self.builder = self.builder.body(body);
374 self
375 }
376
377 pub fn form<T: Serialize + ?Sized>(mut self, form: &T) -> Self {
379 self.builder = self.builder.form(form);
380 self
381 }
382
383 #[cfg(feature = "multipart")]
385 pub fn multipart(mut self, form: reqwest::multipart::Form) -> Self {
386 self.builder = self.builder.multipart(form);
387 self
388 }
389
390 pub async fn send(self) -> Result<Response, FetchError> {
392 let response = self.builder.send().await?;
393 Client::check_status(response).await
394 }
395
396 pub async fn json_response<T: DeserializeOwned>(self) -> Result<T, FetchError> {
398 let response = self.send().await?;
399 Ok(response.json().await?)
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*;
407 use std::time::Duration;
408
409 #[test]
410 fn client_builder_default_timeout() {
411 let builder = ClientBuilder::new();
412 assert_eq!(builder.retries, 3);
413 }
414
415 #[test]
416 fn client_builder_default_retries() {
417 let builder = ClientBuilder::new();
418 assert_eq!(builder.retries, 3);
419 }
420
421 #[test]
422 fn client_builder_default_no_base_url() {
423 let builder = ClientBuilder::new();
424 assert!(builder.base_url.is_none());
425 }
426
427 #[test]
428 fn client_builder_custom_timeout() {
429 let builder = ClientBuilder::new().timeout(Duration::from_secs(10));
430 let client = builder.build();
431 let _ = client.inner();
432 }
433
434 #[test]
435 fn client_builder_custom_retries() {
436 let builder = ClientBuilder::new().retries(5);
437 assert_eq!(builder.retries, 5);
438 }
439
440 #[test]
441 fn client_builder_custom_base_url() {
442 let builder = ClientBuilder::new().base_url("https://api.example.com");
443 let client = builder.build();
444 assert!(client.base_url.is_some());
445 assert_eq!(client.base_url.as_deref(), Some("https://api.example.com"));
446 }
447
448 #[test]
449 fn client_builder_chaining() {
450 let client = ClientBuilder::new()
451 .base_url("https://api.example.com")
452 .timeout(Duration::from_secs(60))
453 .retries(10)
454 .build();
455 assert_eq!(client.base_url.as_deref(), Some("https://api.example.com"));
456 }
457
458 #[test]
459 fn fetch_error_network_display() {
460 let err = FetchError::Network("connection refused".into());
461 let msg = err.to_string();
462 assert!(msg.contains("connection refused"));
463 }
464
465 #[test]
466 fn fetch_error_timeout_display() {
467 let err = FetchError::Timeout(Duration::from_secs(5));
468 let msg = err.to_string();
469 assert!(msg.contains("5s"));
470 }
471
472 #[test]
473 fn fetch_error_status_code_display() {
474 let err = FetchError::StatusCode {
475 status: 404,
476 body: "not found".into(),
477 };
478 let msg = err.to_string();
479 assert!(msg.contains("404"));
480 assert!(msg.contains("not found"));
481 }
482
483 #[test]
484 fn fetch_error_serialization_display() {
485 let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
486 let err = FetchError::Serialization(json_err);
487 let msg = err.to_string();
488 assert!(msg.contains("serialization error"));
489 }
490
491 #[test]
492 fn fetch_error_circuit_open_display() {
493 let err = FetchError::CircuitOpen;
494 let msg = err.to_string();
495 assert!(msg.contains("circuit breaker"));
496 }
497
498 #[test]
499 fn fetch_error_middleware_display() {
500 let err = FetchError::Middleware("tower error".into());
501 let msg = err.to_string();
502 assert!(msg.contains("tower error"));
503 }
504
505 #[test]
506 fn url_resolution_base_with_relative_path() {
507 let client = Client::new();
508 let client = Client {
509 base_url: Some("https://api.example.com".into()),
510 ..client
511 };
512 let resolved = client.resolve_url("/users/1");
513 assert_eq!(resolved, "https://api.example.com/users/1");
514 }
515
516 #[test]
517 fn url_resolution_no_base_url() {
518 let client = Client::new();
519 let resolved = client.resolve_url("/users/1");
520 assert_eq!(resolved, "/users/1");
521 }
522
523 #[test]
524 fn url_resolution_base_trailing_slash() {
525 let client = Client {
526 base_url: Some("https://api.example.com/".into()),
527 inner: Client::new().inner,
528 };
529 let resolved = client.resolve_url("/users/1");
530 assert_eq!(resolved, "https://api.example.com/users/1");
531 }
532
533 #[test]
534 fn url_resolution_path_no_leading_slash() {
535 let client = Client {
536 base_url: Some("https://api.example.com".into()),
537 inner: Client::new().inner,
538 };
539 let resolved = client.resolve_url("users/1");
540 assert_eq!(resolved, "https://api.example.com/users/1");
541 }
542
543 #[test]
544 fn url_resolution_empty_base_trailing_slash() {
545 let client = Client {
546 base_url: Some("https://api.example.com/".into()),
547 inner: Client::new().inner,
548 };
549 let resolved = client.resolve_url("users/1");
550 assert_eq!(resolved, "https://api.example.com/users/1");
551 }
552
553 #[test]
554 fn client_default_trait() {
555 let client = Client::default();
556 assert!(client.base_url.is_none());
557 }
558
559 #[test]
560 fn client_builder_default_trait() {
561 let builder = ClientBuilder::default();
562 assert_eq!(builder.retries, 3);
563 }
564
565 #[test]
568 fn client_builder_new_creates_valid_builder() {
569 let builder = ClientBuilder::new();
570 assert_eq!(builder.retries, 3);
571 assert!(builder.base_url.is_none());
572 let client = builder.build();
573 assert!(client.base_url.is_none());
574 }
575
576 #[test]
577 fn client_builder_base_url_strips_trailing_slash_on_build() {
578 let client = ClientBuilder::new()
579 .base_url("https://api.example.com/")
580 .build();
581 let resolved = client.resolve_url("/users/1");
582 assert_eq!(resolved, "https://api.example.com/users/1");
583 }
584
585 #[test]
586 fn client_builder_timeout_zero() {
587 let client = ClientBuilder::new().timeout(Duration::from_secs(0)).build();
588 let _ = client.inner();
589 }
590
591 #[test]
592 fn client_builder_timeout_large() {
593 let client = ClientBuilder::new()
594 .timeout(Duration::from_secs(3600))
595 .build();
596 let _ = client.inner();
597 }
598
599 #[test]
600 fn client_builder_retries_zero() {
601 let builder = ClientBuilder::new().retries(0);
602 assert_eq!(builder.retries, 0);
603 }
604
605 #[test]
606 fn client_builder_retries_large() {
607 let builder = ClientBuilder::new().retries(1000);
608 assert_eq!(builder.retries, 1000);
609 }
610
611 #[test]
612 fn client_builder_overwrite_retries() {
613 let builder = ClientBuilder::new().retries(5).retries(10);
614 assert_eq!(builder.retries, 10);
615 }
616
617 #[test]
618 fn client_builder_overwrite_timeout() {
619 let client = ClientBuilder::new()
620 .timeout(Duration::from_secs(10))
621 .timeout(Duration::from_secs(60))
622 .build();
623 let _ = client.inner();
624 }
625
626 #[test]
627 fn client_builder_full_chain() {
628 let client = ClientBuilder::new()
629 .base_url("https://api.example.com")
630 .timeout(Duration::from_secs(15))
631 .retries(7)
632 .build();
633 assert_eq!(client.base_url.as_deref(), Some("https://api.example.com"));
634 }
635
636 #[test]
639 fn client_new_creates_default() {
640 let client = Client::new();
641 assert!(client.base_url.is_none());
642 let _ = client.inner();
643 }
644
645 #[test]
646 fn client_builder_factory_method() {
647 let client = Client::builder().base_url("https://example.com").build();
648 assert_eq!(client.base_url.as_deref(), Some("https://example.com"));
649 }
650
651 #[test]
652 fn client_builder_factory_default() {
653 let client = Client::builder().build();
654 assert!(client.base_url.is_none());
655 }
656
657 #[test]
660 fn url_resolution_empty_path_with_base() {
661 let client = Client {
662 base_url: Some("https://api.example.com".into()),
663 inner: Client::new().inner,
664 };
665 let resolved = client.resolve_url("");
666 assert_eq!(resolved, "https://api.example.com/");
667 }
668
669 #[test]
670 fn url_resolution_base_multiple_trailing_slashes() {
671 let client = Client {
672 base_url: Some("https://api.example.com///".into()),
673 inner: Client::new().inner,
674 };
675 let resolved = client.resolve_url("/users/1");
676 assert_eq!(resolved, "https://api.example.com/users/1");
677 }
678
679 #[test]
680 fn url_resolution_full_url_with_base() {
681 let client = Client {
682 base_url: Some("https://api.example.com".into()),
683 inner: Client::new().inner,
684 };
685 let resolved = client.resolve_url("users/1");
686 assert_eq!(resolved, "https://api.example.com/users/1");
687 }
688
689 #[test]
690 fn url_resolution_deeply_nested_path() {
691 let client = Client {
692 base_url: Some("https://api.example.com".into()),
693 inner: Client::new().inner,
694 };
695 let resolved = client.resolve_url("/v1/users/42/posts/7/comments");
696 assert_eq!(
697 resolved,
698 "https://api.example.com/v1/users/42/posts/7/comments"
699 );
700 }
701
702 #[test]
705 fn fetch_error_network_empty_message() {
706 let err = FetchError::Network("".into());
707 let msg = err.to_string();
708 assert!(msg.contains("network error"));
709 }
710
711 #[test]
712 fn fetch_error_network_long_message() {
713 let err = FetchError::Network("DNS resolution failed for api.example.com: NXDOMAIN".into());
714 let msg = err.to_string();
715 assert!(msg.contains("DNS resolution failed"));
716 assert!(msg.contains("NXDOMAIN"));
717 }
718
719 #[test]
720 fn fetch_error_timeout_zero_duration() {
721 let err = FetchError::Timeout(Duration::from_secs(0));
722 let msg = err.to_string();
723 assert!(msg.contains("request timed out"));
724 assert!(msg.contains("0ns"));
725 }
726
727 #[test]
728 fn fetch_error_timeout_large_duration() {
729 let err = FetchError::Timeout(Duration::from_secs(3600));
730 let msg = err.to_string();
731 assert!(msg.contains("request timed out"));
732 assert!(msg.contains("3600s"));
733 }
734
735 #[test]
736 fn fetch_error_status_code_various_codes() {
737 for code in [400, 401, 403, 404, 429, 500, 502, 503] {
738 let err = FetchError::StatusCode {
739 status: code,
740 body: format!("error {code}"),
741 };
742 let msg = err.to_string();
743 assert!(msg.contains(&code.to_string()));
744 assert!(msg.contains("HTTP"));
745 }
746 }
747
748 #[test]
749 fn fetch_error_status_code_empty_body() {
750 let err = FetchError::StatusCode {
751 status: 404,
752 body: String::new(),
753 };
754 let msg = err.to_string();
755 assert!(msg.contains("404"));
756 }
757
758 #[test]
759 fn fetch_error_serialization_various_errors() {
760 let json_err = serde_json::from_str::<serde_json::Value>("").unwrap_err();
761 let err = FetchError::Serialization(json_err);
762 let msg = err.to_string();
763 assert!(msg.contains("serialization error"));
764 }
765
766 #[test]
767 fn fetch_error_debug_format() {
768 let errors = vec![
769 FetchError::Network("test".into()),
770 FetchError::Timeout(Duration::from_secs(1)),
771 FetchError::StatusCode {
772 status: 500,
773 body: "err".into(),
774 },
775 FetchError::CircuitOpen,
776 FetchError::Middleware("mw".into()),
777 ];
778 for err in errors {
779 let debug_str = format!("{:?}", err);
780 assert!(!debug_str.is_empty());
781 }
782 }
783
784 #[test]
785 fn fetch_error_is_std_error() {
786 let err = FetchError::Network("test".into());
787 let std_err: &dyn std::error::Error = &err;
788 assert!(std_err.to_string().contains("network error"));
789 }
790
791 #[test]
792 fn fetch_error_from_serde_json_roundtrip() {
793 let json_err = serde_json::from_str::<serde_json::Value>("invalid json!!").unwrap_err();
794 let msg = json_err.to_string();
795 let fetch_err = FetchError::from(json_err);
796 let fetch_msg = fetch_err.to_string();
797 assert!(fetch_msg.contains("serialization error"));
798 assert!(fetch_msg.contains(&msg));
799 }
800
801 #[test]
804 fn client_clone_preserves_base_url() {
805 let client = Client::builder()
806 .base_url("https://api.example.com")
807 .build();
808 let cloned = client.clone();
809 assert_eq!(cloned.base_url.as_deref(), Some("https://api.example.com"));
810 }
811
812 #[test]
813 fn client_clone_independence() {
814 let client1 = Client::builder().base_url("https://first.com").build();
815 let client2 = client1.clone();
816 assert_eq!(client2.base_url.as_deref(), Some("https://first.com"));
817 }
818
819 #[test]
822 fn client_builder_default_header() {
823 let client = ClientBuilder::new()
824 .default_header("x-custom", "value")
825 .build();
826 let _ = client.inner();
827 }
828
829 #[test]
830 fn client_builder_user_agent() {
831 let client = ClientBuilder::new().user_agent("fetch_kit/0.1").build();
832 let _ = client.inner();
833 }
834
835 #[test]
836 fn client_builder_retry_bounds() {
837 let builder =
838 ClientBuilder::new().retry_bounds(Duration::from_millis(100), Duration::from_secs(5));
839 assert_eq!(builder.retries, 3);
840 let client = builder.build();
841 let _ = client.inner();
842 }
843
844 #[test]
845 fn client_builder_try_build_success() {
846 let result = ClientBuilder::new().try_build();
847 assert!(result.is_ok());
848 }
849
850 #[test]
851 fn fetch_error_build_error_display() {
852 let err = FetchError::BuildError("tls error".into());
853 let msg = err.to_string();
854 assert!(msg.contains("build error"));
855 assert!(msg.contains("tls error"));
856 }
857
858 #[test]
859 fn fetch_error_header_error_display() {
860 let err = FetchError::HeaderError("invalid header".into());
861 let msg = err.to_string();
862 assert!(msg.contains("header error"));
863 assert!(msg.contains("invalid header"));
864 }
865
866 #[test]
867 fn client_get_returns_request_builder() {
868 let client = Client::new();
869 let builder = client.get("https://example.com");
870 let _ = builder;
871 }
872
873 #[test]
874 fn client_post_returns_request_builder() {
875 let client = Client::new();
876 let builder = client.post("https://example.com");
877 let _ = builder;
878 }
879
880 #[test]
881 fn client_put_returns_request_builder() {
882 let client = Client::new();
883 let builder = client.put("https://example.com");
884 let _ = builder;
885 }
886
887 #[test]
888 fn client_delete_returns_request_builder() {
889 let client = Client::new();
890 let builder = client.delete("https://example.com");
891 let _ = builder;
892 }
893
894 #[test]
895 fn request_builder_header_chain() {
896 let client = Client::new();
897 let builder = client
898 .get("https://example.com")
899 .header("X-Test", "value")
900 .bearer_auth("token");
901 let _ = builder;
902 }
903
904 #[test]
905 fn request_builder_query_chain() {
906 let client = Client::new();
907 let builder = client.get("https://example.com").query(&[("key", "value")]);
908 let _ = builder;
909 }
910
911 #[test]
912 fn request_builder_timeout_chain() {
913 let client = Client::new();
914 let builder = client
915 .get("https://example.com")
916 .timeout(Duration::from_secs(5));
917 let _ = builder;
918 }
919
920 #[test]
921 fn fetch_error_from_reqwest_timeout_uses_zero_duration() {
922 let err = FetchError::Timeout(Duration::ZERO);
923 assert!(err.to_string().contains("request timed out"));
924 assert!(err.to_string().contains("0"));
925 }
926}