1use std::sync::Arc;
4
5use polyoxide_rt::Runtime;
6
7use crate::client::{HttpClientBuilder, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS};
8use crate::rate_limit::RetryConfig;
9use crate::transport::HttpTransport;
10
11#[derive(Debug, Clone)]
23pub struct ClientConfig {
24 pub base_url: String,
26 pub timeout_ms: u64,
28 pub pool_size: usize,
30 pub retry_config: Option<RetryConfig>,
32 pub max_concurrent: Option<usize>,
34 pub http_transport: Option<Arc<dyn HttpTransport>>,
38 pub runtime: Option<Arc<dyn Runtime>>,
41 default_max_concurrent: usize,
42}
43
44impl ClientConfig {
45 pub fn new(base_url: impl Into<String>, default_max_concurrent: usize) -> Self {
49 Self {
50 base_url: base_url.into(),
51 timeout_ms: DEFAULT_TIMEOUT_MS,
52 pool_size: DEFAULT_POOL_SIZE,
53 retry_config: None,
54 max_concurrent: None,
55 http_transport: None,
56 runtime: None,
57 default_max_concurrent,
58 }
59 }
60
61 pub fn http_builder(&self) -> HttpClientBuilder {
66 let builder = HttpClientBuilder::new(&self.base_url)
67 .timeout_ms(self.timeout_ms)
68 .pool_size(self.pool_size)
69 .with_max_concurrent(self.max_concurrent.unwrap_or(self.default_max_concurrent));
70 let builder = match &self.runtime {
71 Some(runtime) => builder.runtime(Arc::clone(runtime)),
72 None => builder,
73 };
74 let builder = match &self.http_transport {
75 Some(transport) => builder.http_transport(Arc::clone(transport)),
76 None => builder,
77 };
78 match &self.retry_config {
79 Some(config) => builder.with_retry_config(config.clone()),
80 None => builder,
81 }
82 }
83
84 pub fn runtime_or_default(&self) -> Arc<dyn Runtime> {
92 self.runtime
93 .clone()
94 .unwrap_or_else(polyoxide_rt::default_runtime)
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use std::time::Duration;
101
102 use super::*;
103 use crate::ApiError;
104
105 #[test]
106 fn a_new_config_holds_the_core_defaults() {
107 let config = ClientConfig::new("https://example.com", 4);
108 assert_eq!(config.base_url, "https://example.com");
109 assert_eq!(config.timeout_ms, DEFAULT_TIMEOUT_MS);
110 assert_eq!(config.pool_size, DEFAULT_POOL_SIZE);
111 assert!(config.retry_config.is_none());
112 assert_eq!(config.max_concurrent, None);
113 assert_eq!(config.default_max_concurrent, 4);
114 }
115
116 #[tokio::test]
120 async fn http_builder_applies_every_knob_that_is_set() {
121 let silent = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
123 let mut config = ClientConfig::new(format!("http://{}", silent.local_addr().unwrap()), 4);
124 config.timeout_ms = 200;
125 config.pool_size = 3;
126 config.retry_config = Some(RetryConfig {
127 max_retries: 7,
128 initial_backoff_ms: 11,
129 max_backoff_ms: 13,
130 });
131 config.max_concurrent = Some(2);
132
133 let http = config.http_builder().build().unwrap();
134 assert_eq!(http.base_url.as_str(), config.base_url.clone() + "/");
135 assert_eq!(http.retry_config.max_retries, 7);
136 assert_eq!(http.retry_config.initial_backoff_ms, 11);
137 assert_eq!(http.retry_config.max_backoff_ms, 13);
138
139 let first = http.acquire_concurrency().await;
140 let second = http.acquire_concurrency().await;
141 assert!(first.is_some() && second.is_some());
142 let third =
143 tokio::time::timeout(Duration::from_millis(50), http.acquire_concurrency()).await;
144 assert!(
145 third.is_err(),
146 "a third permit was granted under a limit of 2"
147 );
148 drop((first, second));
149
150 let started = std::time::Instant::now();
151 let err = http.get_bytes("/", &[]).await.unwrap_err();
152 assert!(
153 matches!(&err, ApiError::Transport(e) if e.kind() == crate::TransportKind::Timeout),
154 "expected a timeout, got {err:?}"
155 );
156 assert!(started.elapsed() < Duration::from_secs(5));
157 }
158
159 #[tokio::test]
160 async fn an_unset_concurrency_takes_the_clients_default() {
161 let http = ClientConfig::new("https://example.com", 3)
162 .http_builder()
163 .build()
164 .unwrap();
165 let mut permits = Vec::new();
166 for _ in 0..3 {
167 permits.push(http.acquire_concurrency().await);
168 }
169 assert!(permits.iter().all(Option::is_some));
170 let fourth =
171 tokio::time::timeout(Duration::from_millis(50), http.acquire_concurrency()).await;
172 assert!(
173 fourth.is_err(),
174 "a fourth permit was granted under a default of 3"
175 );
176 assert_eq!(
177 http.retry_config.max_retries,
178 RetryConfig::default().max_retries
179 );
180 }
181
182 #[tokio::test]
185 async fn the_config_leaves_gzip_unset() {
186 let mut server = mockito::Server::new_async().await;
187 let mock = server
188 .mock("GET", "/")
189 .match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
190 .with_status(200)
191 .create_async()
192 .await;
193
194 let http = ClientConfig::new(server.url(), 4)
195 .http_builder()
196 .build()
197 .unwrap();
198 http.get_bytes("/", &[]).await.unwrap();
199 mock.assert_async().await;
200 }
201
202 struct Knobs {
203 config: ClientConfig,
204 }
205
206 impl Knobs {
207 crate::client_config_setters!(config);
208 }
209
210 #[test]
211 fn client_config_setters_set_each_knob() {
212 let knobs = Knobs {
213 config: ClientConfig::new("https://example.com", 4),
214 }
215 .base_url("https://other.example.com")
216 .timeout_ms(1_234)
217 .pool_size(5)
218 .with_retry_config(RetryConfig {
219 max_retries: 9,
220 ..RetryConfig::default()
221 })
222 .max_concurrent(6);
223
224 assert_eq!(knobs.config.base_url, "https://other.example.com");
225 assert_eq!(knobs.config.timeout_ms, 1_234);
226 assert_eq!(knobs.config.pool_size, 5);
227 assert_eq!(knobs.config.retry_config.unwrap().max_retries, 9);
228 assert_eq!(knobs.config.max_concurrent, Some(6));
229 assert_eq!(knobs.config.default_max_concurrent, 4);
230 }
231
232 #[test]
233 fn the_runtime_setter_names_the_configs_runtime() {
234 let runtime: Arc<dyn Runtime> = Arc::new(polyoxide_rt::TokioRuntime);
235 let knobs = Knobs {
236 config: ClientConfig::new("https://example.com", 4),
237 }
238 .runtime(Arc::clone(&runtime));
239
240 assert!(Arc::ptr_eq(
241 knobs.config.runtime.as_ref().unwrap(),
242 &runtime
243 ));
244 }
245
246 #[test]
247 fn the_core_defaults_are_thirty_seconds_and_ten_idle() {
248 let config = ClientConfig::new("https://x", 4);
249 assert_eq!(config.timeout_ms, 30_000);
250 assert_eq!(config.pool_size, 10);
251 assert!(config.retry_config.is_none());
252 }
253
254 #[derive(Debug)]
257 struct Refusing;
258
259 impl crate::HttpTransport for Refusing {
260 fn send(
261 &self,
262 _request: crate::HttpRequest,
263 ) -> polyoxide_rt::BoxFuture<Result<crate::Response, crate::TransportError>> {
264 Box::pin(async {
265 Err(crate::TransportError::new(
266 crate::TransportKind::Other,
267 "refused by the test",
268 ))
269 })
270 }
271 }
272
273 #[tokio::test]
274 async fn an_injected_transport_is_the_one_the_client_sends_on() {
275 let knobs = Knobs {
276 config: ClientConfig::new("https://example.com", 4),
277 }
278 .http_transport(std::sync::Arc::new(Refusing));
279 assert!(knobs.config.http_transport.is_some());
280
281 let http = knobs.config.http_builder().build().unwrap();
282 let err = http.get_bytes("/", &[]).await.unwrap_err();
283 assert_eq!(
284 err.to_string(),
285 "Transport error: other: refused by the test"
286 );
287 }
288}