Skip to main content

polyoxide_core/
config.rs

1//! The transport settings every client builder holds.
2
3use 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/// The transport settings a client builder holds: the base URL, the timeout,
12/// the connection pool, the retry schedule, the concurrency budget, the
13/// runtime and the transport.
14///
15/// A venue's builder holds one, generates its setters with
16/// [`client_config_setters!`](crate::client_config_setters), and builds its
17/// [`HttpClient`](crate::HttpClient) from [`http_builder`](Self::http_builder),
18/// adding the throttle and the retry policy it installs itself, the throttle
19/// on the runtime [`runtime_or_default`](Self::runtime_or_default) names. The
20/// config holds no throttle, policy or `gzip` setting, since those belong to
21/// the venue.
22#[derive(Debug, Clone)]
23pub struct ClientConfig {
24    /// The API's base URL.
25    pub base_url: String,
26    /// Request timeout in milliseconds.
27    pub timeout_ms: u64,
28    /// Idle connections kept per host.
29    pub pool_size: usize,
30    /// The retry schedule; `None` keeps [`RetryConfig::default`].
31    pub retry_config: Option<RetryConfig>,
32    /// In-flight requests allowed; `None` keeps the client's default.
33    pub max_concurrent: Option<usize>,
34    /// The transport every attempt is sent on; `None` keeps the target's
35    /// default, which is built from the timeout, the pool size and the
36    /// client's `gzip` (`ReqwestTransport` on the native targets).
37    pub http_transport: Option<Arc<dyn HttpTransport>>,
38    /// The runtime every wait, clock read and spawn of the client goes
39    /// through; `None` takes [`polyoxide_rt::default_runtime`].
40    pub runtime: Option<Arc<dyn Runtime>>,
41    default_max_concurrent: usize,
42}
43
44impl ClientConfig {
45    /// A config for `base_url` with core's default timeout and pool size, no
46    /// retry config of its own, and `default_max_concurrent` in-flight
47    /// requests unless [`max_concurrent`](Self::max_concurrent) is set.
48    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    /// An [`HttpClientBuilder`] for the base URL, with the timeout, the pool
62    /// size, the concurrency (the set value, else the client's default), the
63    /// runtime, the transport and the retry config if they are set. It sets
64    /// no throttle, policy or `gzip`.
65    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    /// The runtime this config names, else the target's default
85    /// ([`polyoxide_rt::default_runtime`]).
86    ///
87    /// A venue's `build` calls it once and gives the result both to
88    /// [`HttpClientBuilder::runtime`](crate::HttpClientBuilder::runtime) and
89    /// to the throttle it installs, so the client and its throttle keep time
90    /// on one runtime.
91    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    /// The base URL, retry config and concurrency are read off the built
117    /// client, and the timeout is seen to fire. reqwest exposes no pool size,
118    /// so that knob is covered only by `client_config_setters_set_each_knob`.
119    #[tokio::test]
120    async fn http_builder_applies_every_knob_that_is_set() {
121        // Accepted by the kernel, never answered.
122        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    /// Core enables reqwest's `gzip` feature, so a client that leaves `gzip`
183    /// unset asks for it.
184    #[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    /// A transport that fails every attempt, so a request that reaches it
255    /// proves the setter's value is the one the client sends on.
256    #[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}