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
414fn base_has_versioned_api_root(base: &str) -> bool {
415    base.contains("/api/")
416        || base.ends_with("/v4")
417        || base.ends_with("/v3")
418        || base.ends_with("/v2")
419}
420
421/// Join a normalized provider `base_url` with a chat-completions path.
422///
423/// Avoids duplicated path segments when the user supplies a base URL that
424/// already includes a provider API root (for example Zhipu Coding Plan
425/// `https://open.bigmodel.cn/api/coding/paas/v4`).
426pub(crate) fn join_chat_completions_url(base_url: &str, chat_path: &str) -> String {
427    let base = base_url.trim_end_matches('/');
428    if chat_path.is_empty() {
429        return base.to_string();
430    }
431    let mut path = if chat_path.starts_with('/') {
432        chat_path.to_string()
433    } else {
434        format!("/{chat_path}")
435    };
436
437    if base.ends_with("/chat/completions") {
438        return base.to_string();
439    }
440
441    // OpenAI-compatible default path is `/v1/chat/completions`. When the base
442    // already ends at a versioned API root (…/paas/v4, …/api/…), append only
443    // `/chat/completions` so Coding Plan URLs are not turned into …/v4/v1/….
444    if path.starts_with("/v1/") && base_has_versioned_api_root(base) {
445        path = path.replacen("/v1", "", 1);
446    }
447
448    // Built-in Zhipu uses `/api/paas/v4/chat/completions` against the host-only
449    // default base. If the user overrides base_url to a Coding Plan root that
450    // already contains `/paas/v4`, keep only the suffix after that marker.
451    const PAAS_V4: &str = "/paas/v4";
452    if base.contains(PAAS_V4) {
453        if let Some(idx) = path.find(PAAS_V4) {
454            let suffix = &path[idx + PAAS_V4.len()..];
455            return format!("{base}{suffix}");
456        }
457    }
458
459    format!("{base}{path}")
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use std::sync::{Mutex, OnceLock};
466    use tokio::io::{AsyncReadExt, AsyncWriteExt};
467
468    #[test]
469    fn retryable_http_failure_requires_a_typed_transport_error() {
470        let prose = anyhow::anyhow!(
471            "Human-readable text says timeout, connection reset, and TLS handshake."
472        );
473        assert!(!is_retryable_http_failure(&prose));
474
475        let transport = anyhow::Error::new(HttpClientError::transport(
476            "stream request",
477            "opaque diagnostic",
478        ));
479        assert!(is_retryable_http_failure(&transport));
480
481        let cancelled = anyhow::Error::new(HttpClientError::cancelled("stream request"));
482        assert!(!is_retryable_http_failure(&cancelled));
483    }
484
485    #[test]
486    fn hard_connect_failures_are_not_retryable() {
487        let reset = anyhow::Error::new(HttpClientError::transport(
488            "API request",
489            "error sending request for url (https://api.deepseek.com/v1/chat/completions): connection reset",
490        ));
491        assert!(!is_retryable_http_failure(&reset));
492        assert!(is_hard_connect_failure(
493            "error sending request … connection reset by peer"
494        ));
495
496        let refused = anyhow::Error::new(HttpClientError::transport(
497            "API request",
498            "tcp connect error: Connection refused (os error 61)",
499        ));
500        assert!(!is_retryable_http_failure(&refused));
501
502        let timeout = anyhow::Error::new(HttpClientError::transport(
503            "API request",
504            "timed out: operation timed out",
505        ));
506        assert!(is_retryable_http_failure(&timeout));
507    }
508
509    fn proxy_env_lock() -> &'static Mutex<()> {
510        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
511        LOCK.get_or_init(|| Mutex::new(()))
512    }
513
514    fn clear_proxy_env() {
515        for key in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"] {
516            unsafe { env::remove_var(key) };
517        }
518    }
519
520    #[test]
521    fn test_normalize_base_url() {
522        assert_eq!(
523            normalize_base_url("https://api.example.com"),
524            "https://api.example.com"
525        );
526        assert_eq!(
527            normalize_base_url("https://api.example.com/"),
528            "https://api.example.com"
529        );
530        assert_eq!(
531            normalize_base_url("https://api.example.com/v1"),
532            "https://api.example.com"
533        );
534        assert_eq!(
535            normalize_base_url("https://api.example.com/v1/"),
536            "https://api.example.com"
537        );
538    }
539
540    #[test]
541    fn test_normalize_base_url_edge_cases() {
542        assert_eq!(
543            normalize_base_url("http://localhost:8080/v1"),
544            "http://localhost:8080"
545        );
546        assert_eq!(
547            normalize_base_url("http://localhost:8080"),
548            "http://localhost:8080"
549        );
550        assert_eq!(
551            normalize_base_url("https://api.example.com/v1/"),
552            "https://api.example.com"
553        );
554    }
555
556    #[test]
557    fn test_normalize_base_url_multiple_trailing_slashes() {
558        assert_eq!(
559            normalize_base_url("https://api.example.com//"),
560            "https://api.example.com"
561        );
562    }
563
564    #[test]
565    fn test_normalize_base_url_with_port() {
566        assert_eq!(
567            normalize_base_url("http://localhost:11434/v1/"),
568            "http://localhost:11434"
569        );
570    }
571
572    #[test]
573    fn test_normalize_base_url_already_normalized() {
574        assert_eq!(
575            normalize_base_url("https://api.openai.com"),
576            "https://api.openai.com"
577        );
578    }
579
580    #[test]
581    fn test_normalize_base_url_empty_string() {
582        assert_eq!(normalize_base_url(""), "");
583    }
584
585    #[test]
586    fn join_chat_completions_url_openai_default() {
587        assert_eq!(
588            join_chat_completions_url("https://api.openai.com", "/v1/chat/completions"),
589            "https://api.openai.com/v1/chat/completions"
590        );
591    }
592
593    #[test]
594    fn join_chat_completions_url_zhipu_default() {
595        assert_eq!(
596            join_chat_completions_url("https://open.bigmodel.cn", "/api/paas/v4/chat/completions"),
597            "https://open.bigmodel.cn/api/paas/v4/chat/completions"
598        );
599    }
600
601    #[test]
602    fn join_chat_completions_url_coding_plan_openai_compatible() {
603        // Issue #136: custom OpenAI-compatible provider with Coding Plan base.
604        assert_eq!(
605            join_chat_completions_url(
606                "https://open.bigmodel.cn/api/coding/paas/v4",
607                "/v1/chat/completions"
608            ),
609            "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
610        );
611    }
612
613    #[test]
614    fn join_chat_completions_url_coding_plan_builtin_zhipu() {
615        // Issue #136: built-in zhipu with Coding Plan base_url override.
616        assert_eq!(
617            join_chat_completions_url(
618                "https://open.bigmodel.cn/api/coding/paas/v4",
619                "/api/paas/v4/chat/completions"
620            ),
621            "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
622        );
623    }
624
625    #[test]
626    fn join_chat_completions_url_full_endpoint_as_base() {
627        assert_eq!(
628            join_chat_completions_url(
629                "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
630                "/v1/chat/completions"
631            ),
632            "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
633        );
634    }
635
636    #[test]
637    fn test_default_http_client_creation() {
638        let _client = default_http_client();
639    }
640
641    #[tokio::test]
642    async fn test_reqwest_http_client_timeout_applies_to_api_call() {
643        let mut last_refused = None;
644        for _ in 0..3 {
645            let (elapsed, err) = post_to_slow_local_server().await;
646            assert!(
647                elapsed < Duration::from_secs(1),
648                "API timeout should fail quickly, elapsed={elapsed:?}"
649            );
650
651            let msg = format!("{err:?}").to_ascii_lowercase();
652            if msg.contains("connection refused") {
653                last_refused = Some(err);
654                continue;
655            }
656
657            assert!(
658                msg.contains("timed out") || msg.contains("timeout"),
659                "expected timeout error, got: {err:?}"
660            );
661            return;
662        }
663
664        panic!(
665            "local timeout server was not reachable after retries; last error: {:?}",
666            last_refused.expect("at least one connection-refused error")
667        );
668    }
669
670    async fn post_to_slow_local_server() -> (Duration, anyhow::Error) {
671        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
672        let addr = listener.local_addr().unwrap();
673
674        let server = tokio::spawn(async move {
675            let (mut stream, _) = listener.accept().await.unwrap();
676            let mut buf = [0_u8; 1024];
677            let _ = stream.read(&mut buf).await;
678            tokio::time::sleep(Duration::from_millis(250)).await;
679            let _ = stream
680                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
681                .await;
682        });
683
684        // A localhost timeout must exercise reqwest's request deadline, not a
685        // developer/CI HTTP proxy. Build the client while holding the same
686        // environment lock as the proxy configuration tests, then release the
687        // lock before awaiting the network operation.
688        let client = {
689            let _guard = proxy_env_lock().lock().unwrap();
690            clear_proxy_env();
691            ReqwestHttpClient::with_timeout(Duration::from_millis(50)).unwrap()
692        };
693        let started = std::time::Instant::now();
694        let err = match client
695            .post(
696                &format!("http://{addr}/v1/chat/completions"),
697                Vec::new(),
698                &serde_json::json!({"model": "test"}),
699                CancellationToken::new(),
700            )
701            .await
702        {
703            Ok(_) => panic!("expected API timeout error"),
704            Err(err) => err,
705        };
706
707        server.abort();
708        (started.elapsed(), err)
709    }
710
711    #[test]
712    #[cfg(not(windows))]
713    fn test_explicit_proxy_config_from_env_prefers_lowercase_vars() {
714        let _guard = proxy_env_lock().lock().unwrap();
715        clear_proxy_env();
716        unsafe {
717            env::set_var("http_proxy", "http://lower-http:3128");
718            env::set_var("HTTP_PROXY", "http://upper-http:3128");
719            env::set_var("https_proxy", "http://lower-https:3128");
720            env::set_var("HTTPS_PROXY", "http://upper-https:3128");
721        }
722
723        let proxy_config = explicit_proxy_config_from_env();
724
725        assert_eq!(
726            proxy_config,
727            ExplicitProxyConfig {
728                http: Some("http://lower-http:3128".to_string()),
729                https: Some("http://lower-https:3128".to_string()),
730            }
731        );
732        clear_proxy_env();
733    }
734
735    #[test]
736    fn test_explicit_proxy_config_from_env_falls_back_to_http_for_https() {
737        let _guard = proxy_env_lock().lock().unwrap();
738        clear_proxy_env();
739        unsafe {
740            env::set_var("HTTP_PROXY", "http://proxy.example:3128");
741        }
742
743        let proxy_config = explicit_proxy_config_from_env();
744
745        assert_eq!(
746            proxy_config,
747            ExplicitProxyConfig {
748                http: Some("http://proxy.example:3128".to_string()),
749                https: Some("http://proxy.example:3128".to_string()),
750            }
751        );
752        clear_proxy_env();
753    }
754
755    #[test]
756    fn test_build_reqwest_client_accepts_proxy_env_urls() {
757        let _guard = proxy_env_lock().lock().unwrap();
758        clear_proxy_env();
759        unsafe {
760            env::set_var("http_proxy", "http://127.0.0.1:3128");
761            env::set_var("https_proxy", "http://127.0.0.1:3128");
762        }
763
764        let client = build_reqwest_client(None, None);
765        assert!(client.is_ok());
766        clear_proxy_env();
767    }
768}