Skip to main content

a3s_code_core/llm/
http.rs

1//! HTTP utilities and abstraction for LLM API calls
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use futures::StreamExt;
6use std::env;
7use std::pin::Pin;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio_util::sync::CancellationToken;
11
12/// Typed failures emitted before an HTTP response exists.
13///
14/// Retry and tool policies inspect this enum rather than rendered diagnostics.
15#[derive(Debug, thiserror::Error)]
16pub enum HttpClientError {
17    #[error("{operation} was cancelled")]
18    Cancelled { operation: String },
19    #[error("{operation} transport failed: {message}")]
20    Transport { operation: String, message: String },
21    #[error("{operation} request was invalid: {message}")]
22    InvalidRequest { operation: String, message: String },
23}
24
25impl HttpClientError {
26    pub fn cancelled(operation: impl Into<String>) -> Self {
27        Self::Cancelled {
28            operation: operation.into(),
29        }
30    }
31
32    pub fn transport(operation: impl Into<String>, message: impl Into<String>) -> Self {
33        Self::Transport {
34            operation: operation.into(),
35            message: message.into(),
36        }
37    }
38
39    fn from_reqwest(operation: &str, error: reqwest::Error) -> Self {
40        if error.is_builder() {
41            Self::InvalidRequest {
42                operation: operation.to_string(),
43                message: error.to_string(),
44            }
45        } else {
46            Self::transport(operation, error.to_string())
47        }
48    }
49
50    pub fn is_retryable(&self) -> bool {
51        matches!(self, Self::Transport { .. })
52    }
53}
54
55pub(crate) fn is_retryable_http_failure(error: &anyhow::Error) -> bool {
56    error
57        .downcast_ref::<HttpClientError>()
58        .is_some_and(HttpClientError::is_retryable)
59}
60
61/// HTTP response from a non-streaming POST request
62pub struct HttpResponse {
63    pub status: u16,
64    pub body: String,
65}
66
67/// HTTP response from a streaming POST request
68pub struct StreamingHttpResponse {
69    pub status: u16,
70    /// Retry-After header value (if present)
71    pub retry_after: Option<String>,
72    /// Byte stream (valid when status is 2xx)
73    pub byte_stream: Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes>> + Send>>,
74    /// Error body (populated when status is not 2xx)
75    pub error_body: String,
76}
77
78/// Information about an HTTP request for metrics collection.
79#[derive(Debug, Clone)]
80pub struct HttpMetricsRecord {
81    /// The target URL
82    pub url: String,
83    /// HTTP method (currently only POST is used for LLM calls)
84    pub method: String,
85    /// Response status code
86    pub status: u16,
87    /// Request duration in milliseconds
88    pub duration_ms: f64,
89    /// Number of bytes sent (request body size)
90    pub request_bytes: u64,
91    /// Number of bytes received (response body size)
92    pub response_bytes: u64,
93    /// Whether this was a streaming request
94    pub streaming: bool,
95}
96
97/// Callback function type for HTTP metrics collection.
98/// The callback is called after each HTTP request completes.
99pub type HttpMetricsCallback = Arc<dyn Fn(HttpMetricsRecord) + Send + Sync>;
100
101/// Global HTTP metrics callback registry.
102///
103/// Set this to enable HTTP metrics collection for LLM API calls.
104/// The callback will be invoked after each HTTP request completes.
105static HTTP_METRICS_CALLBACK: std::sync::RwLock<Option<HttpMetricsCallback>> =
106    std::sync::RwLock::new(None);
107
108/// Register a global HTTP metrics callback.
109/// The callback will be invoked after each HTTP request completes.
110pub fn set_http_metrics_callback(callback: HttpMetricsCallback) {
111    *HTTP_METRICS_CALLBACK.write().unwrap() = Some(callback);
112}
113
114/// Clear the global HTTP metrics callback.
115pub fn clear_http_metrics_callback() {
116    *HTTP_METRICS_CALLBACK.write().unwrap() = None;
117}
118
119fn maybe_record_metrics(record: HttpMetricsRecord) {
120    if let Some(callback) = HTTP_METRICS_CALLBACK.read().unwrap().as_ref() {
121        callback(record);
122    }
123}
124
125/// Abstraction over HTTP POST requests for LLM API calls.
126///
127/// Enables dependency injection for testing without hitting real HTTP endpoints.
128#[async_trait]
129pub trait HttpClient: Send + Sync {
130    /// Make a POST request and return status + body
131    async fn post(
132        &self,
133        url: &str,
134        headers: Vec<(&str, &str)>,
135        body: &serde_json::Value,
136        cancel_token: CancellationToken,
137    ) -> Result<HttpResponse>;
138
139    /// Make a POST request and return a streaming response.
140    /// If cancel_token is cancelled during the request, the HTTP connection is aborted.
141    async fn post_streaming(
142        &self,
143        url: &str,
144        headers: Vec<(&str, &str)>,
145        body: &serde_json::Value,
146        cancel_token: CancellationToken,
147    ) -> Result<StreamingHttpResponse>;
148}
149
150/// Default HTTP client backed by reqwest
151pub struct ReqwestHttpClient {
152    client: reqwest::Client,
153}
154
155impl ReqwestHttpClient {
156    pub fn new() -> Self {
157        Self {
158            client: build_reqwest_client(None, None).expect("failed to build default HTTP client"),
159        }
160    }
161
162    pub fn with_timeout(timeout: Duration) -> Result<Self> {
163        Ok(Self {
164            client: build_reqwest_client(Some(timeout), None)?,
165        })
166    }
167}
168
169impl Default for ReqwestHttpClient {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175#[async_trait]
176impl HttpClient for ReqwestHttpClient {
177    async fn post(
178        &self,
179        url: &str,
180        headers: Vec<(&str, &str)>,
181        body: &serde_json::Value,
182        cancel_token: CancellationToken,
183    ) -> Result<HttpResponse> {
184        let start = std::time::Instant::now();
185        let request_body = serde_json::to_string(body).unwrap_or_default();
186        let request_bytes = request_body.len() as u64;
187
188        tracing::debug!(
189            "HTTP POST to {}: {}",
190            url,
191            serde_json::to_string_pretty(body)?
192        );
193
194        let mut request = self.client.post(url);
195        for (key, value) in headers {
196            request = request.header(key, value);
197        }
198        request = request.json(body);
199
200        let response = tokio::select! {
201            _ = cancel_token.cancelled() => {
202                return Err(anyhow::Error::new(HttpClientError::cancelled("HTTP request")));
203            }
204            result = request.send() => {
205                result.map_err(|error| {
206                    anyhow::Error::new(HttpClientError::from_reqwest("HTTP request", error))
207                })?
208            }
209        };
210
211        let status = response.status().as_u16();
212        let response_body = response.text().await.map_err(|error| {
213            anyhow::Error::new(HttpClientError::from_reqwest("HTTP response body", error))
214        })?;
215        let response_bytes = response_body.len() as u64;
216        let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
217
218        maybe_record_metrics(HttpMetricsRecord {
219            url: url.to_string(),
220            method: "POST".to_string(),
221            status,
222            duration_ms,
223            request_bytes,
224            response_bytes,
225            streaming: false,
226        });
227
228        Ok(HttpResponse {
229            status,
230            body: response_body,
231        })
232    }
233
234    async fn post_streaming(
235        &self,
236        url: &str,
237        headers: Vec<(&str, &str)>,
238        body: &serde_json::Value,
239        cancel_token: CancellationToken,
240    ) -> Result<StreamingHttpResponse> {
241        let start = std::time::Instant::now();
242        let request_body = serde_json::to_string(body).unwrap_or_default();
243        let request_bytes = request_body.len() as u64;
244
245        let mut request = self.client.post(url);
246        for (key, value) in headers {
247            request = request.header(key, value);
248        }
249        request = request.json(body);
250
251        let response = tokio::select! {
252            _ = cancel_token.cancelled() => {
253                return Err(anyhow::Error::new(HttpClientError::cancelled(
254                    "HTTP streaming request",
255                )));
256            }
257            result = request.send() => {
258                result.map_err(|error| {
259                    anyhow::Error::new(HttpClientError::from_reqwest(
260                        "HTTP streaming request",
261                        error,
262                    ))
263                })?
264            }
265        };
266
267        let status = response.status().as_u16();
268        let retry_after = response
269            .headers()
270            .get("retry-after")
271            .and_then(|v| v.to_str().ok())
272            .map(String::from);
273
274        // For streaming, we record metrics after sending but before consuming the stream
275        // Note: response_bytes is estimated as we can't know the full stream size upfront
276        let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
277        maybe_record_metrics(HttpMetricsRecord {
278            url: url.to_string(),
279            method: "POST".to_string(),
280            status,
281            duration_ms,
282            request_bytes,
283            response_bytes: 0, // Unknown for streaming
284            streaming: true,
285        });
286
287        if (200..300).contains(&status) {
288            let byte_stream = response.bytes_stream().map(|result| {
289                result.map_err(|error| {
290                    anyhow::Error::new(HttpClientError::from_reqwest("HTTP response stream", error))
291                })
292            });
293            Ok(StreamingHttpResponse {
294                status,
295                retry_after,
296                byte_stream: Box::pin(byte_stream),
297                error_body: String::new(),
298            })
299        } else {
300            let error_body = response.text().await.unwrap_or_default();
301            // Return an empty stream for error responses
302            let empty: futures::stream::Empty<Result<bytes::Bytes>> = futures::stream::empty();
303            Ok(StreamingHttpResponse {
304                status,
305                retry_after,
306                byte_stream: Box::pin(empty),
307                error_body,
308            })
309        }
310    }
311}
312
313/// Create a default HTTP client
314pub fn default_http_client() -> Arc<dyn HttpClient> {
315    Arc::new(ReqwestHttpClient::new())
316}
317
318#[derive(Debug, Clone, Default, PartialEq, Eq)]
319struct ExplicitProxyConfig {
320    http: Option<String>,
321    https: Option<String>,
322}
323
324/// Build a reqwest client without consulting system proxy settings.
325///
326/// On macOS test runners, the system proxy lookup path can panic inside the
327/// `system-configuration` crate when no dynamic store is available. Disabling
328/// implicit proxy discovery keeps client construction deterministic while still
329/// honoring standard proxy environment variables explicitly.
330pub(crate) fn build_reqwest_client(
331    timeout: Option<Duration>,
332    default_headers: Option<reqwest::header::HeaderMap>,
333) -> Result<reqwest::Client> {
334    let mut builder = reqwest::Client::builder().no_proxy();
335
336    if let Some(timeout) = timeout {
337        builder = builder.timeout(timeout);
338    }
339
340    if let Some(default_headers) = default_headers {
341        builder = builder.default_headers(default_headers);
342    }
343
344    let proxy_config = explicit_proxy_config_from_env();
345    if let Some(http_proxy) = proxy_config.http.as_deref() {
346        builder = builder.proxy(
347            reqwest::Proxy::http(http_proxy)
348                .with_context(|| format!("Invalid HTTP proxy URL: {http_proxy}"))?,
349        );
350    }
351    if let Some(https_proxy) = proxy_config.https.as_deref() {
352        builder = builder.proxy(
353            reqwest::Proxy::https(https_proxy)
354                .with_context(|| format!("Invalid HTTPS proxy URL: {https_proxy}"))?,
355        );
356    }
357
358    builder.build().context("Failed to build reqwest client")
359}
360
361fn explicit_proxy_config_from_env() -> ExplicitProxyConfig {
362    let http = first_non_empty_env(&["http_proxy", "HTTP_PROXY"]);
363    let https = first_non_empty_env(&["https_proxy", "HTTPS_PROXY"]).or_else(|| http.clone());
364
365    ExplicitProxyConfig { http, https }
366}
367
368fn first_non_empty_env(keys: &[&str]) -> Option<String> {
369    keys.iter().find_map(|key| {
370        env::var(key)
371            .ok()
372            .map(|value| value.trim().to_string())
373            .filter(|value| !value.is_empty())
374    })
375}
376
377/// Normalize base URL by stripping trailing /v1
378pub(crate) fn normalize_base_url(base_url: &str) -> String {
379    base_url
380        .trim_end_matches('/')
381        .trim_end_matches("/v1")
382        .trim_end_matches('/')
383        .to_string()
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use std::sync::{Mutex, OnceLock};
390    use tokio::io::{AsyncReadExt, AsyncWriteExt};
391
392    #[test]
393    fn retryable_http_failure_requires_a_typed_transport_error() {
394        let prose = anyhow::anyhow!(
395            "Human-readable text says timeout, connection reset, and TLS handshake."
396        );
397        assert!(!is_retryable_http_failure(&prose));
398
399        let transport = anyhow::Error::new(HttpClientError::transport(
400            "stream request",
401            "opaque diagnostic",
402        ));
403        assert!(is_retryable_http_failure(&transport));
404
405        let cancelled = anyhow::Error::new(HttpClientError::cancelled("stream request"));
406        assert!(!is_retryable_http_failure(&cancelled));
407    }
408
409    fn proxy_env_lock() -> &'static Mutex<()> {
410        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
411        LOCK.get_or_init(|| Mutex::new(()))
412    }
413
414    fn clear_proxy_env() {
415        for key in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"] {
416            unsafe { env::remove_var(key) };
417        }
418    }
419
420    #[test]
421    fn test_normalize_base_url() {
422        assert_eq!(
423            normalize_base_url("https://api.example.com"),
424            "https://api.example.com"
425        );
426        assert_eq!(
427            normalize_base_url("https://api.example.com/"),
428            "https://api.example.com"
429        );
430        assert_eq!(
431            normalize_base_url("https://api.example.com/v1"),
432            "https://api.example.com"
433        );
434        assert_eq!(
435            normalize_base_url("https://api.example.com/v1/"),
436            "https://api.example.com"
437        );
438    }
439
440    #[test]
441    fn test_normalize_base_url_edge_cases() {
442        assert_eq!(
443            normalize_base_url("http://localhost:8080/v1"),
444            "http://localhost:8080"
445        );
446        assert_eq!(
447            normalize_base_url("http://localhost:8080"),
448            "http://localhost:8080"
449        );
450        assert_eq!(
451            normalize_base_url("https://api.example.com/v1/"),
452            "https://api.example.com"
453        );
454    }
455
456    #[test]
457    fn test_normalize_base_url_multiple_trailing_slashes() {
458        assert_eq!(
459            normalize_base_url("https://api.example.com//"),
460            "https://api.example.com"
461        );
462    }
463
464    #[test]
465    fn test_normalize_base_url_with_port() {
466        assert_eq!(
467            normalize_base_url("http://localhost:11434/v1/"),
468            "http://localhost:11434"
469        );
470    }
471
472    #[test]
473    fn test_normalize_base_url_already_normalized() {
474        assert_eq!(
475            normalize_base_url("https://api.openai.com"),
476            "https://api.openai.com"
477        );
478    }
479
480    #[test]
481    fn test_normalize_base_url_empty_string() {
482        assert_eq!(normalize_base_url(""), "");
483    }
484
485    #[test]
486    fn test_default_http_client_creation() {
487        let _client = default_http_client();
488    }
489
490    #[tokio::test]
491    async fn test_reqwest_http_client_timeout_applies_to_api_call() {
492        let mut last_refused = None;
493        for _ in 0..3 {
494            let (elapsed, err) = post_to_slow_local_server().await;
495            assert!(
496                elapsed < Duration::from_secs(1),
497                "API timeout should fail quickly, elapsed={elapsed:?}"
498            );
499
500            let msg = format!("{err:?}").to_ascii_lowercase();
501            if msg.contains("connection refused") {
502                last_refused = Some(err);
503                continue;
504            }
505
506            assert!(
507                msg.contains("timed out") || msg.contains("timeout"),
508                "expected timeout error, got: {err:?}"
509            );
510            return;
511        }
512
513        panic!(
514            "local timeout server was not reachable after retries; last error: {:?}",
515            last_refused.expect("at least one connection-refused error")
516        );
517    }
518
519    async fn post_to_slow_local_server() -> (Duration, anyhow::Error) {
520        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
521        let addr = listener.local_addr().unwrap();
522
523        let server = tokio::spawn(async move {
524            let (mut stream, _) = listener.accept().await.unwrap();
525            let mut buf = [0_u8; 1024];
526            let _ = stream.read(&mut buf).await;
527            tokio::time::sleep(Duration::from_millis(250)).await;
528            let _ = stream
529                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
530                .await;
531        });
532
533        let client = ReqwestHttpClient::with_timeout(Duration::from_millis(50)).unwrap();
534        let started = std::time::Instant::now();
535        let err = match client
536            .post(
537                &format!("http://{addr}/v1/chat/completions"),
538                Vec::new(),
539                &serde_json::json!({"model": "test"}),
540                CancellationToken::new(),
541            )
542            .await
543        {
544            Ok(_) => panic!("expected API timeout error"),
545            Err(err) => err,
546        };
547
548        server.abort();
549        (started.elapsed(), err)
550    }
551
552    #[test]
553    #[cfg(not(windows))]
554    fn test_explicit_proxy_config_from_env_prefers_lowercase_vars() {
555        let _guard = proxy_env_lock().lock().unwrap();
556        clear_proxy_env();
557        unsafe {
558            env::set_var("http_proxy", "http://lower-http:3128");
559            env::set_var("HTTP_PROXY", "http://upper-http:3128");
560            env::set_var("https_proxy", "http://lower-https:3128");
561            env::set_var("HTTPS_PROXY", "http://upper-https:3128");
562        }
563
564        let proxy_config = explicit_proxy_config_from_env();
565
566        assert_eq!(
567            proxy_config,
568            ExplicitProxyConfig {
569                http: Some("http://lower-http:3128".to_string()),
570                https: Some("http://lower-https:3128".to_string()),
571            }
572        );
573        clear_proxy_env();
574    }
575
576    #[test]
577    fn test_explicit_proxy_config_from_env_falls_back_to_http_for_https() {
578        let _guard = proxy_env_lock().lock().unwrap();
579        clear_proxy_env();
580        unsafe {
581            env::set_var("HTTP_PROXY", "http://proxy.example:3128");
582        }
583
584        let proxy_config = explicit_proxy_config_from_env();
585
586        assert_eq!(
587            proxy_config,
588            ExplicitProxyConfig {
589                http: Some("http://proxy.example:3128".to_string()),
590                https: Some("http://proxy.example:3128".to_string()),
591            }
592        );
593        clear_proxy_env();
594    }
595
596    #[test]
597    fn test_build_reqwest_client_accepts_proxy_env_urls() {
598        let _guard = proxy_env_lock().lock().unwrap();
599        clear_proxy_env();
600        unsafe {
601            env::set_var("http_proxy", "http://127.0.0.1:3128");
602            env::set_var("https_proxy", "http://127.0.0.1:3128");
603        }
604
605        let client = build_reqwest_client(None, None);
606        assert!(client.is_ok());
607        clear_proxy_env();
608    }
609}