Skip to main content

agentic_connect/engine/
http_client.rs

1//! HTTP client with retry, auth, and connection soul integration.
2
3use std::collections::HashMap;
4use std::time::Instant;
5
6use crate::types::{ConnectError, ConnectResult};
7
8/// Result of an HTTP request.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct HttpResponse {
11    pub status: u16,
12    pub headers: HashMap<String, String>,
13    pub body: String,
14    pub latency_ms: u64,
15    pub url: String,
16    pub method: String,
17}
18
19/// Make an HTTP request with timing.
20#[cfg(feature = "http")]
21pub async fn http_request(
22    url: &str,
23    method: &str,
24    headers: Option<&HashMap<String, String>>,
25    body: Option<&str>,
26    timeout_ms: u64,
27) -> ConnectResult<HttpResponse> {
28    let client = reqwest::Client::builder()
29        .timeout(std::time::Duration::from_millis(timeout_ms))
30        .build()
31        .map_err(|e| ConnectError::Http(e.to_string()))?;
32
33    let mut req = match method.to_uppercase().as_str() {
34        "GET" => client.get(url),
35        "POST" => client.post(url),
36        "PUT" => client.put(url),
37        "PATCH" => client.patch(url),
38        "DELETE" => client.delete(url),
39        "HEAD" => client.head(url),
40        other => return Err(ConnectError::NotSupported(format!("HTTP method: {}", other))),
41    };
42
43    if let Some(hdrs) = headers {
44        for (k, v) in hdrs {
45            req = req.header(k.as_str(), v.as_str());
46        }
47    }
48
49    if let Some(b) = body {
50        req = req.body(b.to_string());
51        // Auto-set content-type if not already set
52        if headers.map_or(true, |h| !h.contains_key("content-type")) {
53            req = req.header("content-type", "application/json");
54        }
55    }
56
57    let start = Instant::now();
58    let resp = req.send().await?;
59    let latency_ms = start.elapsed().as_millis() as u64;
60
61    let status = resp.status().as_u16();
62    let resp_headers: HashMap<String, String> = resp
63        .headers()
64        .iter()
65        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
66        .collect();
67    let resp_body = resp.text().await.unwrap_or_default();
68
69    Ok(HttpResponse {
70        status,
71        headers: resp_headers,
72        body: resp_body,
73        latency_ms,
74        url: url.to_string(),
75        method: method.to_uppercase(),
76    })
77}
78
79/// Extract rate limit info from response headers.
80pub fn extract_rate_limit(headers: &HashMap<String, String>) -> Option<(u32, u32, i64)> {
81    let limit = headers.get("x-ratelimit-limit")
82        .or_else(|| headers.get("ratelimit-limit"))
83        .and_then(|v| v.parse::<u32>().ok())?;
84    let remaining = headers.get("x-ratelimit-remaining")
85        .or_else(|| headers.get("ratelimit-remaining"))
86        .and_then(|v| v.parse::<u32>().ok())?;
87    let reset = headers.get("x-ratelimit-reset")
88        .or_else(|| headers.get("ratelimit-reset"))
89        .and_then(|v| v.parse::<i64>().ok())
90        .unwrap_or(0);
91    Some((limit, remaining, reset))
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_extract_rate_limit() {
100        let mut headers = HashMap::new();
101        headers.insert("x-ratelimit-limit".into(), "100".into());
102        headers.insert("x-ratelimit-remaining".into(), "42".into());
103        headers.insert("x-ratelimit-reset".into(), "1710000000".into());
104        let (limit, remaining, reset) = extract_rate_limit(&headers).unwrap();
105        assert_eq!(limit, 100);
106        assert_eq!(remaining, 42);
107        assert_eq!(reset, 1710000000);
108    }
109
110    #[test]
111    fn test_extract_rate_limit_missing() {
112        let headers = HashMap::new();
113        assert!(extract_rate_limit(&headers).is_none());
114    }
115}