1use reqwest::{Client, Method, Response, StatusCode};
36use serde::Serialize;
37use std::time::Duration;
38use thiserror::Error;
39
40#[derive(Debug, Error)]
42pub enum HttpError {
43 #[error("Request failed: {0}")]
45 RequestFailed(String),
46
47 #[error("Connection timeout")]
49 Timeout,
50
51 #[error("Rate limit exceeded")]
53 RateLimitExceeded,
54
55 #[error("Serialization error: {0}")]
57 Serialization(String),
58
59 #[error("Invalid URL: {0}")]
61 InvalidUrl(String),
62
63 #[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 #[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 #[must_use]
91 #[inline]
92 pub const fn is_timeout(&self) -> bool {
93 matches!(self, HttpError::Timeout)
94 }
95
96 #[must_use]
98 #[inline]
99 pub const fn is_rate_limit(&self) -> bool {
100 matches!(self, HttpError::RateLimitExceeded)
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct HttpConfig {
107 pub connect_timeout_ms: u64,
109
110 pub request_timeout_ms: u64,
112
113 pub pool_idle_per_host: usize,
115
116 pub pool_max_per_host: usize,
118
119 pub http2: bool,
121
122 pub user_agent: String,
124
125 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 #[must_use]
146 #[inline]
147 pub fn new() -> Self {
148 Self::default()
149 }
150
151 #[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 #[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 #[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
177pub struct HttpClientPool {
179 client: Client,
180 config: HttpConfig,
181}
182
183impl HttpClientPool {
184 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 pub async fn get(&self, url: &str) -> Result<Response, HttpError> {
201 self.request(Method::GET, url, None::<&()>).await
202 }
203
204 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 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 pub async fn delete(&self, url: &str) -> Result<Response, HttpError> {
216 self.request(Method::DELETE, url, None::<&()>).await
217 }
218
219 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 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 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 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 #[must_use]
280 #[inline]
281 pub fn client(&self) -> &Client {
282 &self.client
283 }
284
285 #[must_use]
287 #[inline]
288 pub fn config(&self) -> &HttpConfig {
289 &self.config
290 }
291
292 pub async fn head(&self, url: &str) -> Result<Response, HttpError> {
294 self.request(Method::HEAD, url, None::<&()>).await
295 }
296
297 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 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 }
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 }
362
363 #[tokio::test]
364 async fn test_http_error_conversion() {
365 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}