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