armature_http_client/
client.rs

1//! HTTP client implementation.
2
3use http::Method;
4use reqwest::Request;
5use std::sync::Arc;
6use tracing::debug;
7
8use crate::{
9    CircuitBreaker, HttpClientConfig, HttpClientError, RequestBuilder, Response, Result,
10    RetryStrategy,
11};
12
13/// HTTP client with retry, circuit breaker, and timeout support.
14#[derive(Clone)]
15pub struct HttpClient {
16    inner: reqwest::Client,
17    config: Arc<HttpClientConfig>,
18    circuit_breaker: Option<Arc<CircuitBreaker>>,
19}
20
21impl HttpClient {
22    /// Create a new HTTP client with the given configuration.
23    pub fn new(config: HttpClientConfig) -> Self {
24        let mut builder = reqwest::Client::builder()
25            .timeout(config.timeout)
26            .connect_timeout(config.connect_timeout)
27            .pool_idle_timeout(config.pool_idle_timeout)
28            .pool_max_idle_per_host(config.pool_max_idle_per_host)
29            .user_agent(&config.user_agent);
30
31        if config.gzip {
32            builder = builder.gzip(true);
33        }
34        if config.brotli {
35            builder = builder.brotli(true);
36        }
37        if config.follow_redirects {
38            builder = builder.redirect(reqwest::redirect::Policy::limited(config.max_redirects));
39        } else {
40            builder = builder.redirect(reqwest::redirect::Policy::none());
41        }
42
43        let inner = builder.build().expect("Failed to build HTTP client");
44
45        let circuit_breaker = config
46            .circuit_breaker
47            .as_ref()
48            .map(|cb_config| Arc::new(CircuitBreaker::new(cb_config.clone())));
49
50        Self {
51            inner,
52            config: Arc::new(config),
53            circuit_breaker,
54        }
55    }
56
57    /// Create a new HTTP client with default configuration.
58    pub fn default_client() -> Self {
59        Self::new(HttpClientConfig::default())
60    }
61
62    /// Get the underlying reqwest client.
63    pub fn inner(&self) -> &reqwest::Client {
64        &self.inner
65    }
66
67    /// Get the client configuration.
68    pub fn config(&self) -> &HttpClientConfig {
69        &self.config
70    }
71
72    /// Create a GET request builder.
73    pub fn get(&self, url: impl Into<String>) -> RequestBuilder<'_> {
74        RequestBuilder::new(self, Method::GET, url.into())
75    }
76
77    /// Create a POST request builder.
78    pub fn post(&self, url: impl Into<String>) -> RequestBuilder<'_> {
79        RequestBuilder::new(self, Method::POST, url.into())
80    }
81
82    /// Create a PUT request builder.
83    pub fn put(&self, url: impl Into<String>) -> RequestBuilder<'_> {
84        RequestBuilder::new(self, Method::PUT, url.into())
85    }
86
87    /// Create a PATCH request builder.
88    pub fn patch(&self, url: impl Into<String>) -> RequestBuilder<'_> {
89        RequestBuilder::new(self, Method::PATCH, url.into())
90    }
91
92    /// Create a DELETE request builder.
93    pub fn delete(&self, url: impl Into<String>) -> RequestBuilder<'_> {
94        RequestBuilder::new(self, Method::DELETE, url.into())
95    }
96
97    /// Create a HEAD request builder.
98    pub fn head(&self, url: impl Into<String>) -> RequestBuilder<'_> {
99        RequestBuilder::new(self, Method::HEAD, url.into())
100    }
101
102    /// Create a request builder with a custom method.
103    pub fn request(&self, method: Method, url: impl Into<String>) -> RequestBuilder<'_> {
104        RequestBuilder::new(self, method, url.into())
105    }
106
107    /// Execute a request with retry and circuit breaker logic.
108    pub(crate) async fn execute(&self, request: Request) -> Result<Response> {
109        // Check circuit breaker
110        if let Some(cb) = &self.circuit_breaker {
111            if !cb.is_allowed() {
112                return Err(HttpClientError::CircuitOpen);
113            }
114        }
115
116        // Execute with retry if configured
117        if let Some(retry_config) = &self.config.retry {
118            self.execute_with_retry(request, retry_config).await
119        } else {
120            self.execute_once(request).await
121        }
122    }
123
124    /// Execute request with retry logic.
125    async fn execute_with_retry(
126        &self,
127        request: Request,
128        retry_config: &crate::RetryConfig,
129    ) -> Result<Response> {
130        let mut attempt = 0;
131        let mut last_error: Option<HttpClientError> = None;
132        let start = std::time::Instant::now();
133
134        loop {
135            // Check max retry time
136            if let Some(max_time) = retry_config.max_retry_time {
137                if start.elapsed() > max_time {
138                    break;
139                }
140            }
141
142            // Clone request for retry (reqwest requests can't be reused)
143            let request_clone = clone_request(&request);
144
145            match self.execute_once(request_clone).await {
146                Ok(response) => {
147                    // Record success with circuit breaker
148                    if let Some(cb) = &self.circuit_breaker {
149                        cb.record_success();
150                    }
151
152                    // Check if response status should trigger retry
153                    if retry_config.should_retry_status(response.status().as_u16())
154                        && attempt < retry_config.max_attempts - 1
155                    {
156                        debug!(
157                            attempt = attempt + 1,
158                            status = %response.status(),
159                            "Retrying request due to status code"
160                        );
161                        last_error = Some(HttpClientError::Response {
162                            status: response.status().as_u16(),
163                            message: "Retriable status code".to_string(),
164                        });
165                        attempt += 1;
166                        let delay = retry_config.delay_for_attempt(attempt);
167                        tokio::time::sleep(delay).await;
168                        continue;
169                    }
170
171                    return Ok(response);
172                }
173                Err(e) => {
174                    // Record failure with circuit breaker
175                    if let Some(cb) = &self.circuit_breaker {
176                        cb.record_failure();
177                    }
178
179                    // Check if error is retryable
180                    if retry_config.should_retry(attempt, &e)
181                        && attempt < retry_config.max_attempts - 1
182                    {
183                        debug!(
184                            attempt = attempt + 1,
185                            error = %e,
186                            "Retrying request due to error"
187                        );
188                        last_error = Some(e);
189                        attempt += 1;
190                        let delay = retry_config.delay_for_attempt(attempt);
191                        tokio::time::sleep(delay).await;
192                        continue;
193                    }
194
195                    return Err(e);
196                }
197            }
198        }
199
200        Err(HttpClientError::RetryExhausted {
201            attempts: attempt + 1,
202            message: last_error
203                .map(|e| e.to_string())
204                .unwrap_or_else(|| "Unknown error".to_string()),
205        })
206    }
207
208    /// Execute request once without retry.
209    async fn execute_once(&self, request: Request) -> Result<Response> {
210        let response = self.inner.execute(request).await?;
211        Ok(Response::from_reqwest(response).await)
212    }
213}
214
215/// Clone a request (best effort - body may be empty).
216fn clone_request(request: &Request) -> Request {
217    let mut builder = reqwest::Request::new(request.method().clone(), request.url().clone());
218    *builder.headers_mut() = request.headers().clone();
219    builder
220}
221
222impl Default for HttpClient {
223    fn default() -> Self {
224        Self::default_client()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::time::Duration;
232
233    #[test]
234    fn test_client_creation() {
235        let client = HttpClient::default();
236        assert!(client.config().gzip);
237        assert!(client.config().brotli);
238    }
239
240    #[test]
241    fn test_client_with_config() {
242        let config = HttpClientConfig::builder()
243            .timeout(Duration::from_secs(60))
244            .base_url("https://api.example.com")
245            .build();
246
247        let client = HttpClient::new(config);
248        assert_eq!(client.config().timeout, Duration::from_secs(60));
249        assert_eq!(
250            client.config().base_url.as_deref(),
251            Some("https://api.example.com")
252        );
253    }
254}