Skip to main content

hyperi_rustlib/http_client/
mod.rs

1// Project:   hyperi-rustlib
2// File:      src/http_client/mod.rs
3// Purpose:   Production HTTP client with retry middleware
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Production HTTP client with automatic retries and timeouts.
10//!
11//! Wraps [`reqwest`] with [`reqwest_middleware`] and [`reqwest_retry`] to
12//! provide exponential backoff for transient errors (5xx, timeouts,
13//! connection failures). Non-retryable errors (4xx) return immediately.
14//!
15//! # Config Cascade
16//!
17//! When the `config` feature is enabled, config is auto-loaded from the
18//! cascade under the `http_client` key:
19//!
20//! ```yaml
21//! http_client:
22//!   timeout_secs: 30
23//!   connect_timeout_secs: 10
24//!   max_retries: 3
25//!   min_retry_interval_ms: 100
26//!   max_retry_interval_ms: 30000
27//!   user_agent: "dfe-fetcher/1.0"
28//! ```
29
30pub mod config;
31
32pub use config::HttpClientConfig;
33
34use reqwest::Response;
35use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
36use reqwest_retry::RetryTransientMiddleware;
37use reqwest_retry::policies::ExponentialBackoff;
38
39/// HTTP client build error.
40#[derive(Debug, thiserror::Error)]
41pub enum HttpClientError {
42    /// Failed to build the underlying reqwest client.
43    #[error("failed to build HTTP client: {0}")]
44    BuildError(#[from] reqwest::Error),
45}
46
47/// Production HTTP client with retry middleware.
48pub struct HttpClient {
49    inner: ClientWithMiddleware,
50    config: HttpClientConfig,
51}
52
53impl HttpClient {
54    /// Create a new HTTP client with the given config.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`HttpClientError::BuildError`] if the underlying reqwest
59    /// client cannot be constructed (typically TLS backend init failure).
60    pub fn new(config: HttpClientConfig) -> Result<Self, HttpClientError> {
61        let retry_policy = ExponentialBackoff::builder()
62            .retry_bounds(
63                std::time::Duration::from_millis(config.min_retry_interval_ms),
64                std::time::Duration::from_millis(config.max_retry_interval_ms),
65            )
66            .build_with_max_retries(config.max_retries);
67
68        let mut builder = reqwest::Client::builder()
69            .timeout(std::time::Duration::from_secs(config.timeout_secs))
70            .connect_timeout(std::time::Duration::from_secs(config.connect_timeout_secs));
71
72        if let Some(ref ua) = config.user_agent {
73            builder = builder.user_agent(ua.clone());
74        }
75
76        let reqwest_client = builder.build()?;
77
78        let client = ClientBuilder::new(reqwest_client)
79            .with(RetryTransientMiddleware::new_with_policy(retry_policy))
80            .build();
81
82        Ok(Self {
83            inner: client,
84            config,
85        })
86    }
87
88    /// Create a client from the config cascade (or defaults).
89    ///
90    /// # Errors
91    ///
92    /// Returns [`HttpClientError::BuildError`] if the underlying reqwest
93    /// client cannot be constructed.
94    pub fn from_cascade() -> Result<Self, HttpClientError> {
95        Self::new(HttpClientConfig::from_cascade())
96    }
97
98    /// Send a GET request.
99    pub async fn get(&self, url: &str) -> Result<Response, reqwest_middleware::Error> {
100        #[cfg(feature = "metrics")]
101        let start = std::time::Instant::now();
102
103        let result = self.inner.get(url).send().await;
104
105        #[cfg(feature = "metrics")]
106        {
107            let status = if result.is_ok() { "success" } else { "error" };
108            metrics::counter!("dfe_http_client_requests_total", "method" => "GET", "status" => status).increment(1);
109            metrics::histogram!("dfe_http_client_duration_seconds", "method" => "GET")
110                .record(start.elapsed().as_secs_f64());
111        }
112
113        result
114    }
115
116    /// Send a POST request with a JSON body.
117    ///
118    /// # Errors
119    ///
120    /// Returns a middleware-wrapped error if JSON serialisation of `body`
121    /// fails, or a network error if the request fails to send. Previously
122    /// a serialisation failure was silently substituted with an empty body
123    /// -- the request would dispatch with no payload, hiding the bug at the
124    /// caller and producing confusing downstream behaviour.
125    pub async fn post_json<T: serde::Serialize + ?Sized>(
126        &self,
127        url: &str,
128        body: &T,
129    ) -> Result<Response, reqwest_middleware::Error> {
130        #[cfg(feature = "metrics")]
131        let start = std::time::Instant::now();
132
133        let body_bytes = serde_json::to_vec(body).map_err(|e| {
134            reqwest_middleware::Error::Middleware(anyhow::anyhow!(
135                "POST {url}: JSON serialise failed: {e}"
136            ))
137        })?;
138
139        let result = self
140            .inner
141            .post(url)
142            .header("content-type", "application/json")
143            .body(body_bytes)
144            .send()
145            .await;
146
147        #[cfg(feature = "metrics")]
148        {
149            let status = if result.is_ok() { "success" } else { "error" };
150            metrics::counter!("dfe_http_client_requests_total", "method" => "POST", "status" => status).increment(1);
151            metrics::histogram!("dfe_http_client_duration_seconds", "method" => "POST")
152                .record(start.elapsed().as_secs_f64());
153        }
154
155        result
156    }
157
158    /// Send a PUT request with a JSON body.
159    ///
160    /// # Errors
161    ///
162    /// See [`Self::post_json`] -- same serialise + send error contract.
163    pub async fn put_json<T: serde::Serialize + ?Sized>(
164        &self,
165        url: &str,
166        body: &T,
167    ) -> Result<Response, reqwest_middleware::Error> {
168        #[cfg(feature = "metrics")]
169        let start = std::time::Instant::now();
170
171        let body_bytes = serde_json::to_vec(body).map_err(|e| {
172            reqwest_middleware::Error::Middleware(anyhow::anyhow!(
173                "PUT {url}: JSON serialise failed: {e}"
174            ))
175        })?;
176
177        let result = self
178            .inner
179            .put(url)
180            .header("content-type", "application/json")
181            .body(body_bytes)
182            .send()
183            .await;
184
185        #[cfg(feature = "metrics")]
186        {
187            let status = if result.is_ok() { "success" } else { "error" };
188            metrics::counter!("dfe_http_client_requests_total", "method" => "PUT", "status" => status).increment(1);
189            metrics::histogram!("dfe_http_client_duration_seconds", "method" => "PUT")
190                .record(start.elapsed().as_secs_f64());
191        }
192
193        result
194    }
195
196    /// Send a DELETE request.
197    pub async fn delete(&self, url: &str) -> Result<Response, reqwest_middleware::Error> {
198        #[cfg(feature = "metrics")]
199        let start = std::time::Instant::now();
200
201        let result = self.inner.delete(url).send().await;
202
203        #[cfg(feature = "metrics")]
204        {
205            let status = if result.is_ok() { "success" } else { "error" };
206            metrics::counter!("dfe_http_client_requests_total", "method" => "DELETE", "status" => status).increment(1);
207            metrics::histogram!("dfe_http_client_duration_seconds", "method" => "DELETE")
208                .record(start.elapsed().as_secs_f64());
209        }
210
211        result
212    }
213
214    /// Access the underlying middleware client for custom requests.
215    #[must_use]
216    pub fn client(&self) -> &ClientWithMiddleware {
217        &self.inner
218    }
219
220    /// Access the current config.
221    #[must_use]
222    pub fn config(&self) -> &HttpClientConfig {
223        &self.config
224    }
225}