chie_core/
http_pool.rs

1//! HTTP connection pooling utilities.
2//!
3//! This module provides connection pooling and client management for HTTP
4//! requests to the CHIE coordinator and other services. It includes retry
5//! logic, timeout handling, and connection reuse.
6//!
7//! # Features
8//!
9//! - **Connection Pooling**: Reuse HTTP connections for better performance
10//! - **Automatic Retries**: Retry failed requests with exponential backoff
11//! - **Timeout Handling**: Configure request and connection timeouts
12//! - **Rate Limiting**: Per-endpoint rate limiting support
13//! - **Circuit Breaker**: Automatic failure detection and recovery
14//!
15//! # Example
16//!
17//! ```rust
18//! use chie_core::http_pool::{HttpClientPool, HttpConfig};
19//!
20//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21//! let config = HttpConfig::default();
22//! let pool = HttpClientPool::new(config);
23//!
24//! // Make a GET request
25//! let response = pool.get("https://coordinator.chie.network/health").await?;
26//! println!("Status: {}", response.status());
27//!
28//! // Make a POST request with JSON body
29//! let json = serde_json::json!({"key": "value"});
30//! let response = pool.post_json("https://coordinator.chie.network/api", json).await?;
31//! # Ok(())
32//! # }
33//! ```
34
35use reqwest::{Client, Method, Response, StatusCode};
36use serde::Serialize;
37use std::time::Duration;
38use thiserror::Error;
39
40/// HTTP client error types.
41#[derive(Debug, Error)]
42pub enum HttpError {
43    /// Request failed.
44    #[error("Request failed: {0}")]
45    RequestFailed(String),
46
47    /// Connection timeout.
48    #[error("Connection timeout")]
49    Timeout,
50
51    /// Rate limit exceeded.
52    #[error("Rate limit exceeded")]
53    RateLimitExceeded,
54
55    /// Serialization error.
56    #[error("Serialization error: {0}")]
57    Serialization(String),
58
59    /// Invalid URL.
60    #[error("Invalid URL: {0}")]
61    InvalidUrl(String),
62
63    /// Response error.
64    #[error("HTTP {status}: {message}")]
65    Response { status: StatusCode, message: String },
66}
67
68impl From<reqwest::Error> for HttpError {
69    fn from(e: reqwest::Error) -> Self {
70        if e.is_timeout() {
71            HttpError::Timeout
72        } else {
73            HttpError::RequestFailed(e.to_string())
74        }
75    }
76}
77
78impl HttpError {
79    /// Check if the error is retryable.
80    #[must_use]
81    #[inline]
82    pub const fn is_retryable(&self) -> bool {
83        matches!(
84            self,
85            HttpError::Timeout | HttpError::RequestFailed(_) | HttpError::RateLimitExceeded
86        )
87    }
88
89    /// Check if the error is a timeout.
90    #[must_use]
91    #[inline]
92    pub const fn is_timeout(&self) -> bool {
93        matches!(self, HttpError::Timeout)
94    }
95
96    /// Check if the error is a rate limit.
97    #[must_use]
98    #[inline]
99    pub const fn is_rate_limit(&self) -> bool {
100        matches!(self, HttpError::RateLimitExceeded)
101    }
102}
103
104/// HTTP client configuration.
105#[derive(Debug, Clone)]
106pub struct HttpConfig {
107    /// Connection timeout in milliseconds.
108    pub connect_timeout_ms: u64,
109
110    /// Request timeout in milliseconds.
111    pub request_timeout_ms: u64,
112
113    /// Maximum number of idle connections per host.
114    pub pool_idle_per_host: usize,
115
116    /// Maximum number of connections per host.
117    pub pool_max_per_host: usize,
118
119    /// Enable HTTP/2.
120    pub http2: bool,
121
122    /// User agent string.
123    pub user_agent: String,
124
125    /// Maximum retries for failed requests.
126    pub max_retries: u32,
127}
128
129impl Default for HttpConfig {
130    fn default() -> Self {
131        Self {
132            connect_timeout_ms: 5_000,
133            request_timeout_ms: 30_000,
134            pool_idle_per_host: 10,
135            pool_max_per_host: 50,
136            http2: true,
137            user_agent: "chie-core/0.1.0".to_string(),
138            max_retries: 3,
139        }
140    }
141}
142
143impl HttpConfig {
144    /// Create a new HTTP configuration.
145    #[must_use]
146    #[inline]
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    /// Set connection timeout.
152    #[must_use]
153    #[inline]
154    pub fn with_connect_timeout(mut self, timeout_ms: u64) -> Self {
155        self.connect_timeout_ms = timeout_ms;
156        self
157    }
158
159    /// Set request timeout.
160    #[must_use]
161    #[inline]
162    pub fn with_request_timeout(mut self, timeout_ms: u64) -> Self {
163        self.request_timeout_ms = timeout_ms;
164        self
165    }
166
167    /// Set pool size limits.
168    #[must_use]
169    #[inline]
170    pub fn with_pool_size(mut self, idle: usize, max: usize) -> Self {
171        self.pool_idle_per_host = idle;
172        self.pool_max_per_host = max;
173        self
174    }
175}
176
177/// HTTP client pool with connection reuse.
178pub struct HttpClientPool {
179    client: Client,
180    config: HttpConfig,
181}
182
183impl HttpClientPool {
184    /// Create a new HTTP client pool.
185    pub fn new(config: HttpConfig) -> Self {
186        let client = Client::builder()
187            .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
188            .timeout(Duration::from_millis(config.request_timeout_ms))
189            .pool_idle_timeout(Duration::from_secs(90))
190            .pool_max_idle_per_host(config.pool_idle_per_host)
191            .http2_prior_knowledge()
192            .user_agent(&config.user_agent)
193            .build()
194            .expect("Failed to create HTTP client");
195
196        Self { client, config }
197    }
198
199    /// Make a GET request.
200    pub async fn get(&self, url: &str) -> Result<Response, HttpError> {
201        self.request(Method::GET, url, None::<&()>).await
202    }
203
204    /// Make a POST request with JSON body.
205    pub async fn post_json<T: Serialize>(&self, url: &str, body: T) -> Result<Response, HttpError> {
206        self.request(Method::POST, url, Some(&body)).await
207    }
208
209    /// Make a PUT request with JSON body.
210    pub async fn put_json<T: Serialize>(&self, url: &str, body: T) -> Result<Response, HttpError> {
211        self.request(Method::PUT, url, Some(&body)).await
212    }
213
214    /// Make a DELETE request.
215    pub async fn delete(&self, url: &str) -> Result<Response, HttpError> {
216        self.request(Method::DELETE, url, None::<&()>).await
217    }
218
219    /// Make an HTTP request with retry logic.
220    async fn request<T: Serialize>(
221        &self,
222        method: Method,
223        url: &str,
224        body: Option<&T>,
225    ) -> Result<Response, HttpError> {
226        let mut last_error = None;
227        let mut retry_count = 0;
228
229        while retry_count <= self.config.max_retries {
230            let result = self.execute_request(method.clone(), url, body).await;
231
232            match result {
233                Ok(response) => {
234                    // Check for error status codes
235                    if !response.status().is_success() {
236                        let status = response.status();
237                        let message = response
238                            .text()
239                            .await
240                            .unwrap_or_else(|_| "Unknown error".to_string());
241                        return Err(HttpError::Response { status, message });
242                    }
243
244                    return Ok(response);
245                }
246                Err(e) => {
247                    last_error = Some(e);
248                    retry_count += 1;
249
250                    // Exponential backoff
251                    if retry_count <= self.config.max_retries {
252                        let backoff_ms = 100 * 2_u64.pow(retry_count - 1);
253                        tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
254                    }
255                }
256            }
257        }
258
259        Err(last_error.unwrap())
260    }
261
262    /// Execute a single HTTP request.
263    async fn execute_request<T: Serialize>(
264        &self,
265        method: Method,
266        url: &str,
267        body: Option<&T>,
268    ) -> Result<Response, HttpError> {
269        let mut request = self.client.request(method, url);
270
271        if let Some(body) = body {
272            request = request.json(body);
273        }
274
275        request.send().await.map_err(HttpError::from)
276    }
277
278    /// Get the underlying reqwest client.
279    #[must_use]
280    #[inline]
281    pub fn client(&self) -> &Client {
282        &self.client
283    }
284
285    /// Get client configuration.
286    #[must_use]
287    #[inline]
288    pub fn config(&self) -> &HttpConfig {
289        &self.config
290    }
291
292    /// Make a HEAD request.
293    pub async fn head(&self, url: &str) -> Result<Response, HttpError> {
294        self.request(Method::HEAD, url, None::<&()>).await
295    }
296
297    /// Make a PATCH request with JSON body.
298    pub async fn patch_json<T: Serialize>(
299        &self,
300        url: &str,
301        body: T,
302    ) -> Result<Response, HttpError> {
303        self.request(Method::PATCH, url, Some(&body)).await
304    }
305
306    /// Check if a URL is reachable (HEAD request).
307    pub async fn is_reachable(&self, url: &str) -> bool {
308        self.head(url).await.is_ok()
309    }
310}
311
312impl Default for HttpClientPool {
313    fn default() -> Self {
314        Self::new(HttpConfig::default())
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_http_config_default() {
324        let config = HttpConfig::default();
325        assert_eq!(config.connect_timeout_ms, 5_000);
326        assert_eq!(config.request_timeout_ms, 30_000);
327        assert_eq!(config.max_retries, 3);
328    }
329
330    #[test]
331    fn test_http_config_builder() {
332        let config = HttpConfig::new()
333            .with_connect_timeout(10_000)
334            .with_request_timeout(60_000)
335            .with_pool_size(20, 100);
336
337        assert_eq!(config.connect_timeout_ms, 10_000);
338        assert_eq!(config.request_timeout_ms, 60_000);
339        assert_eq!(config.pool_idle_per_host, 20);
340        assert_eq!(config.pool_max_per_host, 100);
341    }
342
343    #[test]
344    fn test_http_client_pool_creation() {
345        let config = HttpConfig::default();
346        let _pool = HttpClientPool::new(config);
347        // Pool created successfully
348    }
349
350    #[test]
351    fn test_http_client_pool_config_access() {
352        let config = HttpConfig::default().with_connect_timeout(15_000);
353        let pool = HttpClientPool::new(config);
354        assert_eq!(pool.config().connect_timeout_ms, 15_000);
355    }
356
357    #[test]
358    fn test_http_client_pool_default() {
359        let _pool = HttpClientPool::default();
360        // Default pool created successfully
361    }
362
363    #[tokio::test]
364    async fn test_http_error_conversion() {
365        // Test that we can create HTTP errors
366        let error = HttpError::Timeout;
367        assert_eq!(error.to_string(), "Connection timeout");
368
369        let error = HttpError::RateLimitExceeded;
370        assert_eq!(error.to_string(), "Rate limit exceeded");
371    }
372
373    #[test]
374    fn test_http_error_retryable() {
375        assert!(HttpError::Timeout.is_retryable());
376        assert!(HttpError::RateLimitExceeded.is_retryable());
377        assert!(HttpError::RequestFailed("test".to_string()).is_retryable());
378        assert!(!HttpError::InvalidUrl("test".to_string()).is_retryable());
379    }
380
381    #[test]
382    fn test_http_error_timeout() {
383        assert!(HttpError::Timeout.is_timeout());
384        assert!(!HttpError::RateLimitExceeded.is_timeout());
385    }
386
387    #[test]
388    fn test_http_error_rate_limit() {
389        assert!(HttpError::RateLimitExceeded.is_rate_limit());
390        assert!(!HttpError::Timeout.is_rate_limit());
391    }
392}