Skip to main content

polyoxide_core/
config.rs

1//! The transport settings every client builder holds.
2
3use crate::client::{HttpClientBuilder, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS};
4use crate::rate_limit::RetryConfig;
5
6/// The transport settings a client builder holds: the base URL, the timeout,
7/// the connection pool, the retry schedule and the concurrency budget.
8///
9/// A venue's builder holds one, generates its five setters with
10/// [`client_config_setters!`](crate::client_config_setters), and builds its
11/// [`HttpClient`](crate::HttpClient) from [`http_builder`](Self::http_builder),
12/// adding the throttle and the retry policy it installs itself. The config
13/// holds no throttle, policy or `gzip` setting, since those belong to the
14/// venue.
15#[derive(Debug, Clone)]
16pub struct ClientConfig {
17    /// The API's base URL.
18    pub base_url: String,
19    /// Request timeout in milliseconds.
20    pub timeout_ms: u64,
21    /// Idle connections kept per host.
22    pub pool_size: usize,
23    /// The retry schedule; `None` keeps [`RetryConfig::default`].
24    pub retry_config: Option<RetryConfig>,
25    /// In-flight requests allowed; `None` keeps the client's default.
26    pub max_concurrent: Option<usize>,
27    default_max_concurrent: usize,
28}
29
30impl ClientConfig {
31    /// A config for `base_url` with core's default timeout and pool size, no
32    /// retry config of its own, and `default_max_concurrent` in-flight
33    /// requests unless [`max_concurrent`](Self::max_concurrent) is set.
34    pub fn new(base_url: impl Into<String>, default_max_concurrent: usize) -> Self {
35        Self {
36            base_url: base_url.into(),
37            timeout_ms: DEFAULT_TIMEOUT_MS,
38            pool_size: DEFAULT_POOL_SIZE,
39            retry_config: None,
40            max_concurrent: None,
41            default_max_concurrent,
42        }
43    }
44
45    /// An [`HttpClientBuilder`] for the base URL, with the timeout, the pool
46    /// size, the concurrency (the set value, else the client's default) and
47    /// the retry config if one is set. It sets no throttle, policy or `gzip`.
48    pub fn http_builder(&self) -> HttpClientBuilder {
49        let builder = HttpClientBuilder::new(&self.base_url)
50            .timeout_ms(self.timeout_ms)
51            .pool_size(self.pool_size)
52            .with_max_concurrent(self.max_concurrent.unwrap_or(self.default_max_concurrent));
53        match &self.retry_config {
54            Some(config) => builder.with_retry_config(config.clone()),
55            None => builder,
56        }
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use std::time::Duration;
63
64    use super::*;
65    use crate::ApiError;
66
67    #[test]
68    fn a_new_config_holds_the_core_defaults() {
69        let config = ClientConfig::new("https://example.com", 4);
70        assert_eq!(config.base_url, "https://example.com");
71        assert_eq!(config.timeout_ms, DEFAULT_TIMEOUT_MS);
72        assert_eq!(config.pool_size, DEFAULT_POOL_SIZE);
73        assert!(config.retry_config.is_none());
74        assert_eq!(config.max_concurrent, None);
75        assert_eq!(config.default_max_concurrent, 4);
76    }
77
78    /// The base URL, retry config and concurrency are read off the built
79    /// client, and the timeout is seen to fire. reqwest exposes no pool size,
80    /// so that knob is covered only by `client_config_setters_set_each_knob`.
81    #[tokio::test]
82    async fn http_builder_applies_every_knob_that_is_set() {
83        // Accepted by the kernel, never answered.
84        let silent = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
85        let mut config = ClientConfig::new(format!("http://{}", silent.local_addr().unwrap()), 4);
86        config.timeout_ms = 200;
87        config.pool_size = 3;
88        config.retry_config = Some(RetryConfig {
89            max_retries: 7,
90            initial_backoff_ms: 11,
91            max_backoff_ms: 13,
92        });
93        config.max_concurrent = Some(2);
94
95        let http = config.http_builder().build().unwrap();
96        assert_eq!(http.base_url.as_str(), config.base_url.clone() + "/");
97        assert_eq!(http.retry_config.max_retries, 7);
98        assert_eq!(http.retry_config.initial_backoff_ms, 11);
99        assert_eq!(http.retry_config.max_backoff_ms, 13);
100
101        let first = http.acquire_concurrency().await;
102        let second = http.acquire_concurrency().await;
103        assert!(first.is_some() && second.is_some());
104        let third =
105            tokio::time::timeout(Duration::from_millis(50), http.acquire_concurrency()).await;
106        assert!(
107            third.is_err(),
108            "a third permit was granted under a limit of 2"
109        );
110        drop((first, second));
111
112        let started = std::time::Instant::now();
113        let err = http.get_bytes("/", &[]).await.unwrap_err();
114        assert!(
115            matches!(&err, ApiError::Network(e) if e.is_timeout()),
116            "expected a timeout, got {err:?}"
117        );
118        assert!(started.elapsed() < Duration::from_secs(5));
119    }
120
121    #[tokio::test]
122    async fn an_unset_concurrency_takes_the_clients_default() {
123        let http = ClientConfig::new("https://example.com", 3)
124            .http_builder()
125            .build()
126            .unwrap();
127        let mut permits = Vec::new();
128        for _ in 0..3 {
129            permits.push(http.acquire_concurrency().await);
130        }
131        assert!(permits.iter().all(Option::is_some));
132        let fourth =
133            tokio::time::timeout(Duration::from_millis(50), http.acquire_concurrency()).await;
134        assert!(
135            fourth.is_err(),
136            "a fourth permit was granted under a default of 3"
137        );
138        assert_eq!(
139            http.retry_config.max_retries,
140            RetryConfig::default().max_retries
141        );
142    }
143
144    /// Core enables reqwest's `gzip` feature, so a client that leaves `gzip`
145    /// unset asks for it.
146    #[tokio::test]
147    async fn the_config_leaves_gzip_unset() {
148        let mut server = mockito::Server::new_async().await;
149        let mock = server
150            .mock("GET", "/")
151            .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
152            .with_status(200)
153            .create_async()
154            .await;
155
156        let http = ClientConfig::new(server.url(), 4)
157            .http_builder()
158            .build()
159            .unwrap();
160        http.get_bytes("/", &[]).await.unwrap();
161        mock.assert_async().await;
162    }
163
164    struct Knobs {
165        config: ClientConfig,
166    }
167
168    impl Knobs {
169        crate::client_config_setters!(config);
170    }
171
172    #[test]
173    fn client_config_setters_set_each_knob() {
174        let knobs = Knobs {
175            config: ClientConfig::new("https://example.com", 4),
176        }
177        .base_url("https://other.example.com")
178        .timeout_ms(1_234)
179        .pool_size(5)
180        .with_retry_config(RetryConfig {
181            max_retries: 9,
182            ..RetryConfig::default()
183        })
184        .max_concurrent(6);
185
186        assert_eq!(knobs.config.base_url, "https://other.example.com");
187        assert_eq!(knobs.config.timeout_ms, 1_234);
188        assert_eq!(knobs.config.pool_size, 5);
189        assert_eq!(knobs.config.retry_config.unwrap().max_retries, 9);
190        assert_eq!(knobs.config.max_concurrent, Some(6));
191        assert_eq!(knobs.config.default_max_concurrent, 4);
192    }
193
194    #[test]
195    fn the_core_defaults_are_thirty_seconds_and_ten_idle() {
196        let config = ClientConfig::new("https://x", 4);
197        assert_eq!(config.timeout_ms, 30_000);
198        assert_eq!(config.pool_size, 10);
199        assert!(config.retry_config.is_none());
200    }
201}