Skip to main content

fetch_kit/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3
4//! Resilient HTTP client for Rust.
5//!
6//! `fetch_kit` wraps `reqwest-middleware` with sensible defaults for retries,
7//! timeouts, and typed JSON helpers. Enable the `circuit-breaker` feature for
8//! automatic circuit-breaking on repeated failures.
9
10/// Error types.
11pub mod error;
12/// Middleware implementations.
13pub 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/// A resilient HTTP client with retry, timeout, and typed JSON support.
25#[derive(Clone, Debug)]
26pub struct Client {
27    inner: ClientWithMiddleware,
28    pub(crate) base_url: Option<String>,
29}
30
31/// Builder for constructing a [`Client`] with custom configuration.
32pub 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    /// Create a new builder with defaults (30s timeout, 3 retries, exponential backoff).
52    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    /// Set a base URL that is prepended to all relative paths.
71    pub fn base_url(mut self, url: impl Into<String>) -> Self {
72        self.base_url = Some(url.into());
73        self
74    }
75
76    /// Set the request timeout.
77    pub fn timeout(mut self, timeout: Duration) -> Self {
78        self.reqwest_builder = self.reqwest_builder.timeout(timeout);
79        self
80    }
81
82    /// Set the maximum number of retries.
83    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    /// Set the initial and maximum retry backoff durations.
90    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    /// Add a default header applied to every request (infallible for
100    /// compile-time string literals).
101    ///
102    /// # Panics
103    /// Panics if `key` or `value` is not a valid HTTP header string —
104    /// this is a caller programming error, not a runtime condition.
105    /// Use [`default_headers`](Self::default_headers) with a `HeaderMap`
106    /// for a fallible variant.
107    #[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    /// Set multiple default headers applied to every request.
116    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    /// Set the `User-Agent` header.
126    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    /// Add a raw `reqwest::ClientBuilder` for advanced configuration.
132    pub fn reqwest_builder(mut self, builder: reqwest::ClientBuilder) -> Self {
133        self.reqwest_builder = builder;
134        self
135    }
136
137    /// Attach a circuit breaker to the client (requires the `circuit-breaker` feature).
138    #[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    /// Build the [`Client`].
145    ///
146    /// # Panics
147    ///
148    /// Panics if the underlying `reqwest::Client` cannot be built. This should
149    /// only happen due to process-startup defects (e.g. invalid TLS config),
150    /// not from user input at runtime.
151    #[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    /// Build the [`Client`], returning a [`FetchError`] on failure.
158    pub fn try_build(self) -> Result<Client, FetchError> {
159        let reqwest_client = self
160            .reqwest_builder
161            // Builder-level default headers must actually reach the wire:
162            // they apply to every request, with per-request headers taking
163            // precedence on conflict.
164            .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    /// Create a new client with default settings.
188    pub fn new() -> Self {
189        ClientBuilder::new().build()
190    }
191
192    /// Start building a client with custom settings.
193    pub fn builder() -> ClientBuilder {
194        ClientBuilder::new()
195    }
196
197    /// Construct a [`Client`] from a pre-built [`ClientWithMiddleware`] and
198    /// an optional base URL.
199    ///
200    /// This is useful when you need to attach custom middleware (e.g. a
201    /// circuit breaker from an external registry) that fetch_kit's builder
202    /// does not natively support.
203    pub fn from_parts(inner: ClientWithMiddleware, base_url: Option<String>) -> Self {
204        Self { inner, base_url }
205    }
206
207    /// Resolve a path against the optional base URL.
208    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    /// Create a [`RequestBuilder`] for a GET request.
220    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    /// Create a [`RequestBuilder`] for a POST request.
229    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    /// Create a [`RequestBuilder`] for a PUT request.
238    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    /// Create a [`RequestBuilder`] for a DELETE request.
247    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    /// Perform a GET request and deserialize the JSON response.
256    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    /// Perform a POST request with a JSON body and deserialize the JSON response.
264    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    /// Fetch from `primary_url`; on failure, fall back to `fallback_url`.
276    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    /// Access the inner `reqwest_middleware::ClientWithMiddleware`.
288    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
310/// A wrapper around `reqwest_middleware::RequestBuilder` that provides a
311/// ergonomic API and resolves the URL against the client's base URL.
312pub struct RequestBuilder<'a> {
313    #[allow(dead_code)]
314    client: &'a Client,
315    builder: reqwest_middleware::RequestBuilder,
316}
317
318impl<'a> RequestBuilder<'a> {
319    /// Add a single header.
320    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    /// Set all headers from a `HeaderMap`.
332    pub fn headers(mut self, headers: reqwest::header::HeaderMap) -> Self {
333        self.builder = self.builder.headers(headers);
334        self
335    }
336
337    /// Append query parameters.
338    pub fn query<T: Serialize + ?Sized>(mut self, params: &T) -> Self {
339        self.builder = self.builder.query(params);
340        self
341    }
342
343    /// Override the request timeout.
344    pub fn timeout(mut self, timeout: Duration) -> Self {
345        self.builder = self.builder.timeout(timeout);
346        self
347    }
348
349    /// Set a JSON body.
350    pub fn json<T: Serialize + ?Sized>(mut self, body: &T) -> Self {
351        self.builder = self.builder.json(body);
352        self
353    }
354
355    /// Set a `Bearer` authorization token.
356    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    /// Set HTTP basic auth credentials.
362    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    /// Set the request body directly.
372    pub fn body(mut self, body: impl Into<reqwest::Body>) -> Self {
373        self.builder = self.builder.body(body);
374        self
375    }
376
377    /// Set form-encoded body from a serializable value.
378    pub fn form<T: Serialize + ?Sized>(mut self, form: &T) -> Self {
379        self.builder = self.builder.form(form);
380        self
381    }
382
383    /// Set a multipart form body (requires the `multipart` feature).
384    #[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    /// Send the request.
391    pub async fn send(self) -> Result<Response, FetchError> {
392        let response = self.builder.send().await?;
393        Client::check_status(response).await
394    }
395
396    /// Send the request and deserialize the JSON response.
397    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)] // test assertions unwrap by design
406    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    // ---- Additional ClientBuilder tests ----
566
567    #[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    // ---- Additional Client tests ----
637
638    #[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    // ---- Additional URL resolution tests ----
658
659    #[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    // ---- Additional FetchError display tests ----
703
704    #[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    // ---- Client clone test ----
802
803    #[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    // ---- New builder method tests ----
820
821    #[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}