Skip to main content

liter_llm/http/
transport.rs

1/// HTTP transport configuration and builder.
2///
3/// This module provides fine-grained control over the reqwest HTTP client's
4/// connection pooling, DNS resolution, TLS session resumption, and HTTP version
5/// selection. All settings have sensible defaults that maintain backward
6/// compatibility with existing code.
7///
8/// # Examples
9///
10/// ```no_run
11/// use liter_llm::TransportConfig;
12/// use std::time::Duration;
13///
14/// let config = TransportConfig::default()
15///     .with_pool_max_idle_per_host(16)
16///     .with_pool_idle_timeout(Some(Duration::from_secs(30)))
17///     .with_http2_prior_knowledge(true)
18///     .with_tcp_keepalive(Some(Duration::from_secs(60)));
19/// ```
20use std::time::Duration;
21
22/// HTTP transport configuration for [`reqwest::Client`].
23///
24/// Manages connection pooling, DNS caching, TLS session resumption, and HTTP
25/// version negotiation. All fields have defaults that preserve the current
26/// behavior and do not require explicit configuration.
27#[derive(Clone, Debug)]
28pub struct TransportConfig {
29    /// Maximum number of idle connections per host in the connection pool.
30    ///
31    /// Default: 32. Set to 0 to disable pooling.
32    pub pool_max_idle_per_host: usize,
33
34    /// How long an idle connection remains in the pool before being dropped.
35    ///
36    /// Default: 90 seconds. Set to `None` to disable idle timeout.
37    pub pool_idle_timeout: Option<Duration>,
38
39    /// TCP keepalive interval for idle connections.
40    ///
41    /// Default: 60 seconds. Set to `None` to disable.
42    /// Helps detect stale connections on long-lived clients.
43    pub tcp_keepalive: Option<Duration>,
44
45    /// Enable HTTP/2 prior-knowledge.
46    ///
47    /// When true, the client assumes the server supports HTTP/2 and skips the
48    /// ALPN negotiation for HTTP/1.1 fallback. Saves a round-trip for endpoints
49    /// that advertise HTTP/2 support (OpenAI, Anthropic).
50    ///
51    /// Default: false (disable to avoid breaking custom base URLs).
52    pub http2_prior_knowledge: bool,
53
54    /// DNS cache time-to-live.
55    ///
56    /// Default: 30 seconds. DNS lookups are cached locally for this duration
57    /// before re-resolving. Set to `None` to disable the local DNS cache and
58    /// always re-resolve on each request.
59    pub dns_cache_ttl: Option<Duration>,
60
61    /// Enable HTTP/3 (h3-quinn) support when the feature flag is active.
62    ///
63    /// When true and the `http3` feature is enabled, the client negotiates
64    /// HTTP/3 during connection establishment if available. No effect when
65    /// the feature is disabled; silently ignored.
66    ///
67    /// Default: false.
68    #[cfg(feature = "http3")]
69    pub enable_http3: bool,
70
71    /// Ignore ambient proxy settings (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`
72    /// and the system proxy configuration).
73    ///
74    /// Any active outbound policy already disables proxies, so the guarded
75    /// resolver sees every upstream host. Set this to rule out a machine-level
76    /// proxy as a credential exposure path when the outbound policy is `Off`,
77    /// without changing the process environment.
78    ///
79    /// Redirects need no switch: the authenticated client never follows them,
80    /// so credentials and request bodies cannot be forwarded to another origin.
81    ///
82    /// Default: false.
83    pub disable_proxies: bool,
84}
85
86impl Default for TransportConfig {
87    fn default() -> Self {
88        Self {
89            pool_max_idle_per_host: 32,
90            pool_idle_timeout: Some(Duration::from_secs(90)),
91            tcp_keepalive: Some(Duration::from_secs(60)),
92            http2_prior_knowledge: false,
93            dns_cache_ttl: Some(Duration::from_secs(30)),
94            #[cfg(feature = "http3")]
95            enable_http3: false,
96            disable_proxies: false,
97        }
98    }
99}
100
101impl TransportConfig {
102    /// Create a new transport config with defaults.
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// Set the maximum idle connections per host.
108    pub fn with_pool_max_idle_per_host(mut self, count: usize) -> Self {
109        self.pool_max_idle_per_host = count;
110        self
111    }
112
113    /// Set the idle connection timeout.
114    pub fn with_pool_idle_timeout(mut self, timeout: Option<Duration>) -> Self {
115        self.pool_idle_timeout = timeout;
116        self
117    }
118
119    /// Set the TCP keepalive interval.
120    pub fn with_tcp_keepalive(mut self, interval: Option<Duration>) -> Self {
121        self.tcp_keepalive = interval;
122        self
123    }
124
125    /// Enable or disable HTTP/2 prior-knowledge.
126    pub fn with_http2_prior_knowledge(mut self, enabled: bool) -> Self {
127        self.http2_prior_knowledge = enabled;
128        self
129    }
130
131    /// Set the DNS cache TTL.
132    pub fn with_dns_cache_ttl(mut self, ttl: Option<Duration>) -> Self {
133        self.dns_cache_ttl = ttl;
134        self
135    }
136
137    /// Enable or disable HTTP/3 (when feature is active).
138    #[cfg(feature = "http3")]
139    pub fn with_http3(mut self, enabled: bool) -> Self {
140        self.enable_http3 = enabled;
141        self
142    }
143
144    /// Ignore (or honor) ambient proxy settings from the environment.
145    pub fn with_proxies_disabled(mut self, disabled: bool) -> Self {
146        self.disable_proxies = disabled;
147        self
148    }
149
150    /// Apply all transport settings to a [`reqwest::ClientBuilder`].
151    ///
152    /// This method converts transport fields into concrete reqwest builder
153    /// calls. Call it once during client construction and chain the returned
154    /// builder.
155    ///
156    /// Not available on WASM targets: the browser fetch API controls transport
157    /// settings and does not expose builder-level hooks.
158    ///
159    /// # Fields and their reqwest mapping
160    ///
161    /// | Field | reqwest method |
162    /// |---|---|
163    /// | `pool_max_idle_per_host` | `.pool_max_idle_per_host(n)` |
164    /// | `pool_idle_timeout` | `.pool_idle_timeout(t)` |
165    /// | `tcp_keepalive` | `.tcp_keepalive(t)` |
166    /// | `http2_prior_knowledge` | `.http2_prior_knowledge()` (when `true`) |
167    /// | `dns_cache_ttl` | `.dns_resolver(...)` during native client construction |
168    /// | `enable_http3` | `.http3_prior_knowledge()` (when `true` and `http3` feature is on) |
169    /// | `disable_proxies` | `.no_proxy()` (when `true`) |
170    ///
171    /// # DNS TTL
172    ///
173    /// reqwest 0.13 exposes DNS customization through `ClientBuilder::dns_resolver`
174    /// rather than a direct TTL setter. The native client construction path
175    /// installs a resolver that combines this TTL with outbound-policy DNS
176    /// validation, so DNS rebinding protection is preserved when both features
177    /// are active.
178    #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
179    pub fn apply_to_builder(&self, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
180        let builder = builder
181            .pool_max_idle_per_host(self.pool_max_idle_per_host)
182            .pool_idle_timeout(self.pool_idle_timeout)
183            .tcp_keepalive(self.tcp_keepalive);
184
185        let builder = if self.http2_prior_knowledge {
186            builder.http2_prior_knowledge()
187        } else {
188            builder
189        };
190
191        let builder = if self.disable_proxies {
192            builder.no_proxy()
193        } else {
194            builder
195        };
196
197        #[cfg(feature = "http3")]
198        let _ = self.enable_http3;
199
200        builder
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_default_config() {
210        let cfg = TransportConfig::default();
211        assert_eq!(cfg.pool_max_idle_per_host, 32);
212        assert_eq!(cfg.pool_idle_timeout, Some(Duration::from_secs(90)));
213        assert_eq!(cfg.tcp_keepalive, Some(Duration::from_secs(60)));
214        assert!(!cfg.http2_prior_knowledge);
215        assert_eq!(cfg.dns_cache_ttl, Some(Duration::from_secs(30)));
216        #[cfg(feature = "http3")]
217        assert!(!cfg.enable_http3);
218        assert!(!cfg.disable_proxies);
219    }
220
221    #[test]
222    fn test_builder_chain() {
223        let cfg = TransportConfig::new()
224            .with_pool_max_idle_per_host(16)
225            .with_pool_idle_timeout(Some(Duration::from_secs(45)))
226            .with_tcp_keepalive(Some(Duration::from_secs(120)))
227            .with_http2_prior_knowledge(true)
228            .with_dns_cache_ttl(Some(Duration::from_secs(60)));
229
230        assert_eq!(cfg.pool_max_idle_per_host, 16);
231        assert_eq!(cfg.pool_idle_timeout, Some(Duration::from_secs(45)));
232        assert_eq!(cfg.tcp_keepalive, Some(Duration::from_secs(120)));
233        assert!(cfg.http2_prior_knowledge);
234        assert_eq!(cfg.dns_cache_ttl, Some(Duration::from_secs(60)));
235    }
236
237    #[test]
238    fn test_disable_pooling() {
239        let cfg = TransportConfig::new().with_pool_max_idle_per_host(0);
240        assert_eq!(cfg.pool_max_idle_per_host, 0);
241    }
242
243    #[test]
244    fn test_disable_keepalive() {
245        let cfg = TransportConfig::new().with_tcp_keepalive(None);
246        assert_eq!(cfg.tcp_keepalive, None);
247    }
248
249    #[test]
250    fn test_disable_dns_cache() {
251        let cfg = TransportConfig::new().with_dns_cache_ttl(None);
252        assert_eq!(cfg.dns_cache_ttl, None);
253    }
254
255    #[cfg(feature = "http3")]
256    #[test]
257    fn test_http3_flag() {
258        let cfg = TransportConfig::new().with_http3(true);
259        assert!(cfg.enable_http3);
260    }
261
262    /// Verify that `apply_to_builder` actually produces a buildable reqwest
263    /// client when a non-default `TransportConfig` is supplied.  If the method
264    /// were not wired or had an incompatible API call this would fail to compile
265    /// or panic at `.build()`.
266    #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
267    #[test]
268    fn test_apply_to_builder_builds_client_with_non_default_config() {
269        let cfg = TransportConfig::new()
270            .with_pool_max_idle_per_host(128)
271            .with_pool_idle_timeout(Some(Duration::from_secs(45)))
272            .with_tcp_keepalive(Some(Duration::from_secs(30)))
273            .with_http2_prior_knowledge(false)
274            .with_dns_cache_ttl(Some(Duration::from_secs(60)));
275
276        let builder = reqwest::Client::builder();
277        let builder = cfg.apply_to_builder(builder);
278        let client = builder.build();
279        assert!(
280            client.is_ok(),
281            "reqwest::Client::build() failed with non-default TransportConfig"
282        );
283    }
284
285    /// Verify that `apply_to_builder` with `http2_prior_knowledge = true` also
286    /// produces a valid client.
287    #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
288    #[test]
289    fn test_apply_to_builder_with_http2_prior_knowledge() {
290        let cfg = TransportConfig::new().with_http2_prior_knowledge(true);
291        let client = cfg.apply_to_builder(reqwest::Client::builder()).build();
292        assert!(
293            client.is_ok(),
294            "reqwest::Client::build() failed with http2_prior_knowledge=true"
295        );
296    }
297
298    /// Verify that `apply_to_builder` with pooling disabled produces a valid client.
299    #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
300    #[test]
301    fn test_apply_to_builder_with_pooling_disabled() {
302        let cfg = TransportConfig::new()
303            .with_pool_max_idle_per_host(0)
304            .with_pool_idle_timeout(None)
305            .with_tcp_keepalive(None)
306            .with_dns_cache_ttl(None);
307
308        let client = cfg.apply_to_builder(reqwest::Client::builder()).build();
309        assert!(client.is_ok(), "reqwest::Client::build() failed with pooling disabled");
310    }
311
312    /// `disable_proxies` must clear every proxy, including ones reqwest would
313    /// pick up from the environment, so requests connect to the origin directly.
314    #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
315    #[tokio::test]
316    async fn test_disable_proxies_bypasses_configured_proxy() {
317        use tokio::io::{AsyncReadExt, AsyncWriteExt};
318
319        async fn serve_once(listener: tokio::net::TcpListener) {
320            let (mut socket, _) = listener.accept().await.expect("accept");
321            let mut buf = [0u8; 1024];
322            let _ = socket.read(&mut buf).await;
323            let _ = socket
324                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin")
325                .await;
326        }
327
328        let origin = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind origin");
329        let origin_url = format!("http://{}/", origin.local_addr().expect("origin address"));
330        let proxy = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind proxy");
331        let proxy_url = format!("http://{}", proxy.local_addr().expect("proxy address"));
332        let proxy_hit = tokio::spawn(async move { proxy.accept().await.is_ok() });
333        let origin_task = tokio::spawn(serve_once(origin));
334
335        let builder = reqwest::Client::builder().proxy(reqwest::Proxy::all(&proxy_url).expect("proxy URL"));
336        let client = TransportConfig::new()
337            .with_proxies_disabled(true)
338            .apply_to_builder(builder)
339            .build()
340            .expect("client builds");
341        let body = client
342            .get(&origin_url)
343            .send()
344            .await
345            .expect("request reaches origin")
346            .text()
347            .await
348            .expect("body");
349
350        assert_eq!(body, "origin");
351        origin_task.await.expect("origin served");
352        assert!(!proxy_hit.is_finished(), "the proxy must not be contacted");
353        proxy_hit.abort();
354    }
355}