use std::sync::Arc;
use polyoxide_rt::Runtime;
use crate::client::{HttpClientBuilder, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS};
use crate::rate_limit::RetryConfig;
use crate::transport::HttpTransport;
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub base_url: String,
pub timeout_ms: u64,
pub pool_size: usize,
pub retry_config: Option<RetryConfig>,
pub max_concurrent: Option<usize>,
pub http_transport: Option<Arc<dyn HttpTransport>>,
pub runtime: Option<Arc<dyn Runtime>>,
default_max_concurrent: usize,
}
impl ClientConfig {
pub fn new(base_url: impl Into<String>, default_max_concurrent: usize) -> Self {
Self {
base_url: base_url.into(),
timeout_ms: DEFAULT_TIMEOUT_MS,
pool_size: DEFAULT_POOL_SIZE,
retry_config: None,
max_concurrent: None,
http_transport: None,
runtime: None,
default_max_concurrent,
}
}
pub fn http_builder(&self) -> HttpClientBuilder {
let builder = HttpClientBuilder::new(&self.base_url)
.timeout_ms(self.timeout_ms)
.pool_size(self.pool_size)
.with_max_concurrent(self.max_concurrent.unwrap_or(self.default_max_concurrent));
let builder = match &self.runtime {
Some(runtime) => builder.runtime(Arc::clone(runtime)),
None => builder,
};
let builder = match &self.http_transport {
Some(transport) => builder.http_transport(Arc::clone(transport)),
None => builder,
};
match &self.retry_config {
Some(config) => builder.with_retry_config(config.clone()),
None => builder,
}
}
pub fn runtime_or_default(&self) -> Arc<dyn Runtime> {
self.runtime
.clone()
.unwrap_or_else(polyoxide_rt::default_runtime)
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::ApiError;
#[test]
fn a_new_config_holds_the_core_defaults() {
let config = ClientConfig::new("https://example.com", 4);
assert_eq!(config.base_url, "https://example.com");
assert_eq!(config.timeout_ms, DEFAULT_TIMEOUT_MS);
assert_eq!(config.pool_size, DEFAULT_POOL_SIZE);
assert!(config.retry_config.is_none());
assert_eq!(config.max_concurrent, None);
assert_eq!(config.default_max_concurrent, 4);
}
#[tokio::test]
async fn http_builder_applies_every_knob_that_is_set() {
let silent = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut config = ClientConfig::new(format!("http://{}", silent.local_addr().unwrap()), 4);
config.timeout_ms = 200;
config.pool_size = 3;
config.retry_config = Some(RetryConfig {
max_retries: 7,
initial_backoff_ms: 11,
max_backoff_ms: 13,
});
config.max_concurrent = Some(2);
let http = config.http_builder().build().unwrap();
assert_eq!(http.base_url.as_str(), config.base_url.clone() + "/");
assert_eq!(http.retry_config.max_retries, 7);
assert_eq!(http.retry_config.initial_backoff_ms, 11);
assert_eq!(http.retry_config.max_backoff_ms, 13);
let first = http.acquire_concurrency().await;
let second = http.acquire_concurrency().await;
assert!(first.is_some() && second.is_some());
let third =
tokio::time::timeout(Duration::from_millis(50), http.acquire_concurrency()).await;
assert!(
third.is_err(),
"a third permit was granted under a limit of 2"
);
drop((first, second));
let started = std::time::Instant::now();
let err = http.get_bytes("/", &[]).await.unwrap_err();
assert!(
matches!(&err, ApiError::Transport(e) if e.kind() == crate::TransportKind::Timeout),
"expected a timeout, got {err:?}"
);
assert!(started.elapsed() < Duration::from_secs(5));
}
#[tokio::test]
async fn an_unset_concurrency_takes_the_clients_default() {
let http = ClientConfig::new("https://example.com", 3)
.http_builder()
.build()
.unwrap();
let mut permits = Vec::new();
for _ in 0..3 {
permits.push(http.acquire_concurrency().await);
}
assert!(permits.iter().all(Option::is_some));
let fourth =
tokio::time::timeout(Duration::from_millis(50), http.acquire_concurrency()).await;
assert!(
fourth.is_err(),
"a fourth permit was granted under a default of 3"
);
assert_eq!(
http.retry_config.max_retries,
RetryConfig::default().max_retries
);
}
#[tokio::test]
async fn the_config_leaves_gzip_unset() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/")
.match_header("accept-encoding", mockito::Matcher::Regex("gzip".into()))
.with_status(200)
.create_async()
.await;
let http = ClientConfig::new(server.url(), 4)
.http_builder()
.build()
.unwrap();
http.get_bytes("/", &[]).await.unwrap();
mock.assert_async().await;
}
struct Knobs {
config: ClientConfig,
}
impl Knobs {
crate::client_config_setters!(config);
}
#[test]
fn client_config_setters_set_each_knob() {
let knobs = Knobs {
config: ClientConfig::new("https://example.com", 4),
}
.base_url("https://other.example.com")
.timeout_ms(1_234)
.pool_size(5)
.with_retry_config(RetryConfig {
max_retries: 9,
..RetryConfig::default()
})
.max_concurrent(6);
assert_eq!(knobs.config.base_url, "https://other.example.com");
assert_eq!(knobs.config.timeout_ms, 1_234);
assert_eq!(knobs.config.pool_size, 5);
assert_eq!(knobs.config.retry_config.unwrap().max_retries, 9);
assert_eq!(knobs.config.max_concurrent, Some(6));
assert_eq!(knobs.config.default_max_concurrent, 4);
}
#[test]
fn the_runtime_setter_names_the_configs_runtime() {
let runtime: Arc<dyn Runtime> = Arc::new(polyoxide_rt::TokioRuntime);
let knobs = Knobs {
config: ClientConfig::new("https://example.com", 4),
}
.runtime(Arc::clone(&runtime));
assert!(Arc::ptr_eq(
knobs.config.runtime.as_ref().unwrap(),
&runtime
));
}
#[test]
fn the_core_defaults_are_thirty_seconds_and_ten_idle() {
let config = ClientConfig::new("https://x", 4);
assert_eq!(config.timeout_ms, 30_000);
assert_eq!(config.pool_size, 10);
assert!(config.retry_config.is_none());
}
#[derive(Debug)]
struct Refusing;
impl crate::HttpTransport for Refusing {
fn send(
&self,
_request: crate::HttpRequest,
) -> polyoxide_rt::BoxFuture<Result<crate::Response, crate::TransportError>> {
Box::pin(async {
Err(crate::TransportError::new(
crate::TransportKind::Other,
"refused by the test",
))
})
}
}
#[tokio::test]
async fn an_injected_transport_is_the_one_the_client_sends_on() {
let knobs = Knobs {
config: ClientConfig::new("https://example.com", 4),
}
.http_transport(std::sync::Arc::new(Refusing));
assert!(knobs.config.http_transport.is_some());
let http = knobs.config.http_builder().build().unwrap();
let err = http.get_bytes("/", &[]).await.unwrap_err();
assert_eq!(
err.to_string(),
"Transport error: other: refused by the test"
);
}
}