gemini_genai_rs/client/
http.rs

1//! HTTP client for Gemini REST APIs.
2//!
3//! Wraps `reqwest` with retry logic, telemetry, and typed errors.
4//! Feature-gated behind `http`.
5
6use std::time::Duration;
7
8use crate::telemetry;
9
10/// Configuration for the HTTP client.
11#[derive(Debug, Clone)]
12pub struct HttpConfig {
13    /// Request timeout.
14    pub timeout: Duration,
15    /// Maximum number of retries on transient errors (5xx, network).
16    pub max_retries: u32,
17    /// Base delay for exponential backoff between retries.
18    pub retry_base_delay: Duration,
19    /// Maximum delay between retries.
20    pub retry_max_delay: Duration,
21    /// User-Agent header value.
22    pub user_agent: String,
23}
24
25impl Default for HttpConfig {
26    fn default() -> Self {
27        Self {
28            timeout: Duration::from_secs(60),
29            max_retries: 3,
30            retry_base_delay: Duration::from_millis(500),
31            retry_max_delay: Duration::from_secs(30),
32            user_agent: format!("gemini-live/{}", env!("CARGO_PKG_VERSION")),
33        }
34    }
35}
36
37/// Errors from HTTP client operations.
38#[derive(Debug, thiserror::Error)]
39pub enum HttpError {
40    /// HTTP request failed.
41    #[error("HTTP request failed: {0}")]
42    Request(#[from] reqwest::Error),
43
44    /// Server returned an error status.
45    #[error("API error {status}: {message}")]
46    ApiError {
47        /// HTTP status code.
48        status: u16,
49        /// Error message from the API.
50        message: String,
51        /// Optional response body.
52        body: Option<serde_json::Value>,
53    },
54
55    /// Authentication error.
56    #[error("Auth error: {0}")]
57    Auth(String),
58
59    /// JSON deserialization error.
60    #[error("JSON parse error: {0}")]
61    Json(#[from] serde_json::Error),
62
63    /// All retries exhausted.
64    #[error("All {attempts} retries exhausted: {last_error}")]
65    RetriesExhausted {
66        /// Number of retry attempts made.
67        attempts: u32,
68        /// Error message from the last attempt.
69        last_error: String,
70    },
71}
72
73/// HTTP client wrapping reqwest with retry and telemetry.
74pub struct HttpClient {
75    inner: reqwest::Client,
76    config: HttpConfig,
77}
78
79impl HttpClient {
80    /// Create a new HTTP client with the given configuration.
81    pub fn new(config: HttpConfig) -> Self {
82        let inner = reqwest::Client::builder()
83            .timeout(config.timeout)
84            .user_agent(&config.user_agent)
85            .build()
86            .expect("Failed to build reqwest client");
87        Self { inner, config }
88    }
89
90    /// POST JSON to a URL and return the parsed response.
91    pub async fn post_json(
92        &self,
93        url: &str,
94        auth_headers: Vec<(String, String)>,
95        body: &impl serde::Serialize,
96    ) -> Result<serde_json::Value, HttpError> {
97        self.request_with_retry("POST", url, auth_headers, Some(body))
98            .await
99    }
100
101    /// PATCH JSON to a URL and return the parsed response.
102    pub async fn patch_json(
103        &self,
104        url: &str,
105        auth_headers: Vec<(String, String)>,
106        body: &impl serde::Serialize,
107    ) -> Result<serde_json::Value, HttpError> {
108        self.request_with_retry("PATCH", url, auth_headers, Some(body))
109            .await
110    }
111
112    /// PUT JSON to a URL and return the parsed response.
113    pub async fn put_json(
114        &self,
115        url: &str,
116        auth_headers: Vec<(String, String)>,
117        body: &impl serde::Serialize,
118    ) -> Result<serde_json::Value, HttpError> {
119        self.request_with_retry("PUT", url, auth_headers, Some(body))
120            .await
121    }
122
123    /// GET a URL and return the parsed response.
124    pub async fn get_json(
125        &self,
126        url: &str,
127        auth_headers: Vec<(String, String)>,
128    ) -> Result<serde_json::Value, HttpError> {
129        self.request_with_retry::<()>("GET", url, auth_headers, None)
130            .await
131    }
132
133    /// DELETE a URL and return the parsed response.
134    pub async fn delete(
135        &self,
136        url: &str,
137        auth_headers: Vec<(String, String)>,
138    ) -> Result<serde_json::Value, HttpError> {
139        self.request_with_retry::<()>("DELETE", url, auth_headers, None)
140            .await
141    }
142
143    /// Execute an HTTP request with exponential backoff retry on transient errors.
144    async fn request_with_retry<B: serde::Serialize>(
145        &self,
146        method: &str,
147        url: &str,
148        auth_headers: Vec<(String, String)>,
149        body: Option<&B>,
150    ) -> Result<serde_json::Value, HttpError> {
151        let mut last_error = String::new();
152
153        for attempt in 0..=self.config.max_retries {
154            if attempt > 0 {
155                let delay = self.backoff_delay(attempt);
156                telemetry::logging::log_http_retry(url, attempt, delay.as_millis() as u64);
157                tokio::time::sleep(delay).await;
158            }
159
160            telemetry::logging::log_http_request(method, url);
161            let _span = telemetry::spans::http_request_span(method, url);
162
163            let start = std::time::Instant::now();
164            match self.execute_request(method, url, &auth_headers, body).await {
165                Ok(response) => {
166                    let status = response.status();
167                    let duration_ms = start.elapsed().as_millis() as f64;
168                    telemetry::metrics::record_http_request(method, status.as_u16(), duration_ms);
169                    telemetry::logging::log_http_response(status.as_u16(), duration_ms);
170
171                    if status.is_success() {
172                        let body = response.text().await?;
173                        if body.is_empty() {
174                            return Ok(serde_json::Value::Null);
175                        }
176                        return Ok(serde_json::from_str(&body)?);
177                    }
178
179                    let status_code = status.as_u16();
180                    let body_text = response.text().await.unwrap_or_default();
181                    let body_json: Option<serde_json::Value> =
182                        serde_json::from_str(&body_text).ok();
183
184                    // Extract error message
185                    let message = body_json
186                        .as_ref()
187                        .and_then(|v| v.get("error"))
188                        .and_then(|v| v.get("message"))
189                        .and_then(|v| v.as_str())
190                        .unwrap_or(&body_text)
191                        .to_string();
192
193                    // Retry on 5xx and 429 (rate limit)
194                    if is_retryable_status(status_code) && attempt < self.config.max_retries {
195                        last_error = format!("HTTP {status_code}: {message}");
196                        continue;
197                    }
198
199                    return Err(HttpError::ApiError {
200                        status: status_code,
201                        message,
202                        body: body_json,
203                    });
204                }
205                Err(e) => {
206                    let duration_ms = start.elapsed().as_millis() as f64;
207                    telemetry::metrics::record_http_request(method, 0, duration_ms);
208
209                    if is_retryable_error(&e) && attempt < self.config.max_retries {
210                        last_error = e.to_string();
211                        continue;
212                    }
213                    return Err(HttpError::Request(e));
214                }
215            }
216        }
217
218        Err(HttpError::RetriesExhausted {
219            attempts: self.config.max_retries + 1,
220            last_error,
221        })
222    }
223
224    /// Execute a single HTTP request (no retry).
225    async fn execute_request<B: serde::Serialize>(
226        &self,
227        method: &str,
228        url: &str,
229        auth_headers: &[(String, String)],
230        body: Option<&B>,
231    ) -> Result<reqwest::Response, reqwest::Error> {
232        let mut builder = match method {
233            "POST" => self.inner.post(url),
234            "GET" => self.inner.get(url),
235            "DELETE" => self.inner.delete(url),
236            "PATCH" => self.inner.patch(url),
237            "PUT" => self.inner.put(url),
238            _ => self
239                .inner
240                .request(reqwest::Method::from_bytes(method.as_bytes()).unwrap(), url),
241        };
242
243        for (key, value) in auth_headers {
244            builder = builder.header(key, value);
245        }
246
247        if let Some(body) = body {
248            builder = builder.json(body);
249        }
250
251        builder.send().await
252    }
253
254    /// Calculate exponential backoff delay.
255    fn backoff_delay(&self, attempt: u32) -> Duration {
256        let delay = self.config.retry_base_delay * 2u32.saturating_pow(attempt.saturating_sub(1));
257        std::cmp::min(delay, self.config.retry_max_delay)
258    }
259}
260
261/// Whether an HTTP status code is retryable.
262fn is_retryable_status(status: u16) -> bool {
263    status == 429 || (500..600).contains(&status)
264}
265
266/// Whether a reqwest error is retryable (network, timeout).
267fn is_retryable_error(e: &reqwest::Error) -> bool {
268    e.is_timeout() || e.is_connect() || e.is_request()
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn default_config() {
277        let config = HttpConfig::default();
278        assert_eq!(config.timeout, Duration::from_secs(60));
279        assert_eq!(config.max_retries, 3);
280        assert!(config.user_agent.starts_with("gemini-live/"));
281    }
282
283    #[test]
284    fn backoff_delay_calculation() {
285        let client = HttpClient::new(HttpConfig {
286            retry_base_delay: Duration::from_millis(100),
287            retry_max_delay: Duration::from_secs(5),
288            ..HttpConfig::default()
289        });
290        assert_eq!(client.backoff_delay(1), Duration::from_millis(100));
291        assert_eq!(client.backoff_delay(2), Duration::from_millis(200));
292        assert_eq!(client.backoff_delay(3), Duration::from_millis(400));
293    }
294
295    #[test]
296    fn backoff_delay_capped() {
297        let client = HttpClient::new(HttpConfig {
298            retry_base_delay: Duration::from_secs(1),
299            retry_max_delay: Duration::from_secs(5),
300            ..HttpConfig::default()
301        });
302        // 2^9 = 512 seconds, should be capped at 5 seconds
303        assert_eq!(client.backoff_delay(10), Duration::from_secs(5));
304    }
305
306    #[test]
307    fn retryable_status_codes() {
308        assert!(is_retryable_status(429));
309        assert!(is_retryable_status(500));
310        assert!(is_retryable_status(503));
311        assert!(is_retryable_status(599));
312        assert!(!is_retryable_status(400));
313        assert!(!is_retryable_status(404));
314        assert!(!is_retryable_status(200));
315    }
316}