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