Skip to main content

opi_ai/
http.rs

1//! Shared HTTP client with connection pooling and proxy support (tasks 3.13, 3.12).
2//!
3//! Provides [`HttpClient`] wrapping `reqwest::Client` with tuned pool
4//! defaults, proxy configuration, and [`HttpClientBuilder`] for custom
5//! configuration. All providers should store `Arc<HttpClient>` to avoid
6//! per-request client allocation.
7
8use std::time::Duration;
9
10/// Default maximum idle connections per host in the connection pool.
11const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 10;
12
13/// Default idle timeout for pooled connections.
14const DEFAULT_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
15
16/// Proxy configuration for an [`HttpClient`].
17///
18/// When `url` is `Some`, the client routes requests through the proxy.
19/// `no_proxy` is a comma-separated list of host patterns that bypass the
20/// proxy (e.g. `"localhost,*.internal"`).
21#[derive(Debug, Clone, Default)]
22pub struct ProxyConfig {
23    /// Proxy URL (e.g. `http://proxy.example.com:8080`).
24    pub url: Option<String>,
25    /// Comma-separated host patterns to exclude from proxying.
26    pub no_proxy: Option<String>,
27}
28
29impl ProxyConfig {
30    fn normalize(&mut self) {
31        if self.url.as_ref().is_some_and(|s| s.trim().is_empty()) {
32            self.url = None;
33        }
34        if self.no_proxy.as_ref().is_some_and(|s| s.trim().is_empty()) {
35            self.no_proxy = None;
36        }
37    }
38}
39
40/// Shared HTTP client with tuned connection-pool and proxy settings.
41///
42/// Wraps a `reqwest::Client` with sensible defaults for LLM provider use:
43/// connection pooling enabled, limited idle connections per host, a
44/// reasonable idle timeout, and optional proxy configuration. Designed to be
45/// held as `Arc<HttpClient>` per provider or shared across providers.
46#[derive(Debug)]
47pub struct HttpClient {
48    inner: reqwest::Client,
49    max_idle_per_host: usize,
50    idle_timeout: Duration,
51    proxy_config: ProxyConfig,
52}
53
54impl HttpClient {
55    /// Create a new client with default pool settings and no proxy.
56    ///
57    /// Defaults:
58    /// - `pool_max_idle_per_host`: 10
59    /// - `pool_idle_timeout`: 90 seconds
60    /// - proxy: none
61    pub fn new() -> Self {
62        HttpClientBuilder::new()
63            .build()
64            .expect("HttpClient construction should not fail with valid defaults")
65    }
66
67    /// Access the underlying `reqwest::Client`.
68    pub fn client(&self) -> &reqwest::Client {
69        &self.inner
70    }
71
72    /// Return the pool configuration as `(max_idle_per_host, idle_timeout)`.
73    pub fn pool_config(&self) -> (usize, Duration) {
74        (self.max_idle_per_host, self.idle_timeout)
75    }
76
77    /// Return the resolved proxy configuration.
78    pub fn proxy_config(&self) -> &ProxyConfig {
79        &self.proxy_config
80    }
81}
82
83impl Default for HttpClient {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89/// Builder for custom `HttpClient` instances.
90pub struct HttpClientBuilder {
91    max_idle_per_host: usize,
92    idle_timeout: Duration,
93    proxy_config: ProxyConfig,
94}
95
96impl HttpClientBuilder {
97    /// Create a builder with default settings.
98    pub fn new() -> Self {
99        Self {
100            max_idle_per_host: DEFAULT_POOL_MAX_IDLE_PER_HOST,
101            idle_timeout: DEFAULT_POOL_IDLE_TIMEOUT,
102            proxy_config: ProxyConfig::default(),
103        }
104    }
105
106    /// Set the maximum number of idle connections per host.
107    pub fn max_idle_per_host(mut self, n: usize) -> Self {
108        self.max_idle_per_host = n;
109        self
110    }
111
112    /// Set the idle timeout for pooled connections.
113    pub fn idle_timeout(mut self, d: Duration) -> Self {
114        self.idle_timeout = d;
115        self
116    }
117
118    /// Set explicit proxy configuration.
119    ///
120    /// When set, this takes precedence over environment variable detection.
121    /// An empty `url` is normalized to `None` (no proxy).
122    pub fn proxy(mut self, config: ProxyConfig) -> Self {
123        self.proxy_config = config;
124        self.proxy_config.normalize();
125        self
126    }
127
128    /// Build the `HttpClient`.
129    ///
130    /// Returns an error if the underlying `reqwest::Client` fails to
131    /// construct (e.g. invalid TLS or proxy URL).
132    pub fn build(self) -> Result<HttpClient, reqwest::Error> {
133        let mut builder = reqwest::Client::builder()
134            .pool_max_idle_per_host(self.max_idle_per_host)
135            .pool_idle_timeout(Some(self.idle_timeout));
136
137        if let Some(ref url) = self.proxy_config.url {
138            let mut proxy = reqwest::Proxy::all(url)?;
139            if let Some(ref np) = self.proxy_config.no_proxy {
140                proxy = proxy.no_proxy(reqwest::NoProxy::from_string(np));
141            }
142            builder = builder.proxy(proxy);
143        }
144
145        let inner = builder.build()?;
146        Ok(HttpClient {
147            inner,
148            max_idle_per_host: self.max_idle_per_host,
149            idle_timeout: self.idle_timeout,
150            proxy_config: self.proxy_config,
151        })
152    }
153}
154
155impl Default for HttpClientBuilder {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161/// Resolve proxy configuration from explicit values.
162///
163/// `https_proxy` takes precedence over `http_proxy` when both are set.
164/// Empty strings are treated as `None`. This is the pure-logic core used by
165/// [`proxy_from_env`] and config resolution.
166pub fn resolve_proxy(
167    http_proxy: Option<&str>,
168    https_proxy: Option<&str>,
169    no_proxy: Option<&str>,
170) -> ProxyConfig {
171    let url = https_proxy
172        .and_then(|s| {
173            if s.trim().is_empty() {
174                None
175            } else {
176                Some(s.to_string())
177            }
178        })
179        .or_else(|| {
180            http_proxy.and_then(|s| {
181                if s.trim().is_empty() {
182                    None
183                } else {
184                    Some(s.to_string())
185                }
186            })
187        });
188    let np = no_proxy.and_then(|s| {
189        if s.trim().is_empty() {
190            None
191        } else {
192            Some(s.to_string())
193        }
194    });
195    ProxyConfig { url, no_proxy: np }
196}
197
198/// Read an environment variable, preferring uppercase over lowercase.
199fn env_var_case_insensitive(upper: &str, lower: &str) -> Option<String> {
200    std::env::var(upper)
201        .ok()
202        .or_else(|| std::env::var(lower).ok())
203}
204
205/// Resolve proxy configuration from standard environment variables.
206///
207/// Checks both uppercase and lowercase variants of `HTTP_PROXY`,
208/// `HTTPS_PROXY`, and `NO_PROXY`. Uppercase takes precedence when both
209/// cases exist. `HTTPS_PROXY` takes precedence over `HTTP_PROXY`.
210pub fn proxy_from_env() -> ProxyConfig {
211    let https_proxy = env_var_case_insensitive("HTTPS_PROXY", "https_proxy");
212    let http_proxy = env_var_case_insensitive("HTTP_PROXY", "http_proxy");
213    let no_proxy = env_var_case_insensitive("NO_PROXY", "no_proxy");
214    resolve_proxy(
215        http_proxy.as_deref(),
216        https_proxy.as_deref(),
217        no_proxy.as_deref(),
218    )
219}
220
221/// Redact credentials embedded in a proxy URL for safe display.
222///
223/// Converts `http://user:pass@host:port` to `http://***:***@host:port`.
224/// URLs without credentials are returned unchanged.
225pub fn redact_proxy_credentials(url: &str) -> String {
226    if let Some(scheme_end) = url.find("://") {
227        let after_scheme = &url[scheme_end + 3..];
228        if let Some(at_pos) = after_scheme.find('@') {
229            let credentials = &after_scheme[..at_pos];
230            let host_part = &after_scheme[at_pos + 1..];
231            if credentials.contains(':') {
232                return format!("{}***:***@{}", &url[..scheme_end + 3], host_part);
233            }
234            // User without password
235            return format!("{}***@{}", &url[..scheme_end + 3], host_part);
236        }
237    }
238    url.to_string()
239}