1use crate::client::{HttpClientBuilder, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS};
4use crate::rate_limit::RetryConfig;
5
6#[derive(Debug, Clone)]
16pub struct ClientConfig {
17 pub base_url: String,
19 pub timeout_ms: u64,
21 pub pool_size: usize,
23 pub retry_config: Option<RetryConfig>,
25 pub max_concurrent: Option<usize>,
27 default_max_concurrent: usize,
28}
29
30impl ClientConfig {
31 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 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 #[tokio::test]
82 async fn http_builder_applies_every_knob_that_is_set() {
83 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 #[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}