Skip to main content

punch_runtime/
http_pool.rs

1//! Shared HTTP connection pool for outbound LLM and tool calls.
2//!
3//! The [`HttpPool`] wraps a single [`reqwest::Client`] configured with
4//! connection-pool settings so that all drivers share one pool instead of
5//! each creating their own.
6
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11/// Configuration for the shared HTTP connection pool.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct HttpPoolConfig {
14    /// Maximum idle connections kept per host (default: 10).
15    pub pool_max_idle_per_host: usize,
16    /// How long an idle connection stays in the pool in seconds (default: 90).
17    pub pool_idle_timeout_secs: u64,
18    /// TCP connect timeout in seconds (default: 10).
19    pub connect_timeout_secs: u64,
20    /// Overall request timeout in seconds (default: 120).
21    pub request_timeout_secs: u64,
22    /// User-Agent header sent with every request (default: "Punch/0.1").
23    pub user_agent: String,
24}
25
26impl Default for HttpPoolConfig {
27    fn default() -> Self {
28        Self {
29            pool_max_idle_per_host: 10,
30            pool_idle_timeout_secs: 90,
31            connect_timeout_secs: 10,
32            request_timeout_secs: 120,
33            user_agent: "Punch/0.1".to_string(),
34        }
35    }
36}
37
38/// A shared HTTP connection pool backed by a single [`reqwest::Client`].
39///
40/// Create one of these at startup and pass the inner client to each driver
41/// so they all share the same connection pool and timeout settings.
42#[derive(Debug, Clone)]
43pub struct HttpPool {
44    client: reqwest::Client,
45    config: HttpPoolConfig,
46}
47
48impl HttpPool {
49    /// Build a new pool from the given configuration.
50    pub fn new(config: HttpPoolConfig) -> Self {
51        let client = reqwest::Client::builder()
52            .pool_max_idle_per_host(config.pool_max_idle_per_host)
53            .pool_idle_timeout(Duration::from_secs(config.pool_idle_timeout_secs))
54            .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
55            .timeout(Duration::from_secs(config.request_timeout_secs))
56            .user_agent(&config.user_agent)
57            .build()
58            .unwrap_or_default();
59
60        tracing::info!(
61            pool_max_idle = config.pool_max_idle_per_host,
62            idle_timeout_secs = config.pool_idle_timeout_secs,
63            connect_timeout_secs = config.connect_timeout_secs,
64            request_timeout_secs = config.request_timeout_secs,
65            user_agent = %config.user_agent,
66            "HTTP connection pool initialized"
67        );
68
69        Self { client, config }
70    }
71
72    /// Return a reference to the shared [`reqwest::Client`].
73    pub fn client(&self) -> &reqwest::Client {
74        &self.client
75    }
76
77    /// Return a reference to the pool configuration.
78    pub fn config(&self) -> &HttpPoolConfig {
79        &self.config
80    }
81}
82
83impl Default for HttpPool {
84    fn default() -> Self {
85        Self::new(HttpPoolConfig::default())
86    }
87}
88
89// ---------------------------------------------------------------------------
90// Tests
91// ---------------------------------------------------------------------------
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn pool_creates_client_with_defaults() {
99        let pool = HttpPool::default();
100        let _client = pool.client();
101    }
102
103    #[test]
104    fn pool_creates_client_with_custom_config() {
105        let config = HttpPoolConfig {
106            pool_max_idle_per_host: 20,
107            pool_idle_timeout_secs: 30,
108            connect_timeout_secs: 5,
109            request_timeout_secs: 60,
110            user_agent: "TestAgent/1.0".to_string(),
111        };
112        let pool = HttpPool::new(config);
113        let _client = pool.client();
114    }
115
116    #[test]
117    fn default_config_values_are_sensible() {
118        let config = HttpPoolConfig::default();
119        assert_eq!(config.pool_max_idle_per_host, 10);
120        assert_eq!(config.pool_idle_timeout_secs, 90);
121        assert_eq!(config.connect_timeout_secs, 10);
122        assert_eq!(config.request_timeout_secs, 120);
123        assert_eq!(config.user_agent, "Punch/0.1");
124    }
125
126    #[test]
127    fn pool_is_clone() {
128        let pool = HttpPool::default();
129        let pool2 = pool.clone();
130        // Both clones share the same underlying connection pool
131        // (reqwest::Client uses Arc internally).
132        let _c1 = pool.client();
133        let _c2 = pool2.client();
134    }
135
136    #[test]
137    fn custom_config_overrides_work() {
138        let config = HttpPoolConfig {
139            pool_max_idle_per_host: 1,
140            pool_idle_timeout_secs: 1,
141            connect_timeout_secs: 1,
142            request_timeout_secs: 1,
143            user_agent: "Custom/2.0".to_string(),
144        };
145        let pool = HttpPool::new(config.clone());
146        assert_eq!(pool.config().pool_max_idle_per_host, 1);
147        assert_eq!(pool.config().request_timeout_secs, 1);
148        assert_eq!(pool.config().user_agent, "Custom/2.0");
149    }
150
151    #[test]
152    fn config_accessor_returns_stored_values() {
153        let pool = HttpPool::default();
154        assert_eq!(pool.config().pool_max_idle_per_host, 10);
155    }
156}