use std::time::Duration;
use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::de::DeserializeOwned;
use crate::error::{api_error_from_bytes, BoxxkiteError};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const EXEC_TIMEOUT_HEADROOM: Duration = Duration::from_secs(15);
const LOCALHOST_HOSTNAMES: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
#[derive(Clone, Debug)]
pub struct Client {
pub(crate) http: reqwest::Client,
pub(crate) base_url: String,
pub(crate) api_key: String,
pub(crate) retry: RetryConfig,
}
impl Client {
pub fn new(
base_url: impl Into<String>,
api_key: impl Into<String>,
) -> Result<Self, BoxxkiteError> {
ClientBuilder::new()
.base_url(base_url)
.api_key(api_key)
.build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub(crate) fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
self.http
.request(method, self.url(path))
.bearer_auth(&self.api_key)
}
pub(crate) fn request_with_auth(
&self,
method: Method,
path: &str,
token: &str,
) -> RequestBuilder {
self.http.request(method, self.url(path)).bearer_auth(token)
}
async fn execute_with_retry(
&self,
builder: RequestBuilder,
) -> Result<reqwest::Response, BoxxkiteError> {
let request = builder.build().map_err(BoxxkiteError::from)?;
if self.retry.max_retries == 0 || request.try_clone().is_none() {
return self.http.execute(request).await.map_err(BoxxkiteError::from);
}
let idempotent = is_idempotent(request.method());
let mut attempt: u32 = 0;
loop {
let this = request
.try_clone()
.expect("request body was clonable at the pre-loop check");
match self.http.execute(this).await {
Ok(resp) => {
let status = resp.status();
if attempt < self.retry.max_retries && should_retry_status(status, idempotent) {
let retry_after = parse_retry_after(resp.headers());
let delay = self.retry.backoff(attempt, retry_after);
attempt += 1;
tokio::time::sleep(delay).await;
continue;
}
return Ok(resp);
}
Err(err) => {
let transient = err.is_timeout() || err.is_connect() || err.is_request();
if attempt < self.retry.max_retries && idempotent && transient {
let delay = self.retry.backoff(attempt, None);
attempt += 1;
tokio::time::sleep(delay).await;
continue;
}
return Err(BoxxkiteError::from(err));
}
}
}
}
pub(crate) async fn send<T: DeserializeOwned>(
&self,
builder: RequestBuilder,
) -> Result<T, BoxxkiteError> {
let resp = self.execute_with_retry(builder).await?;
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
return Err(api_error_from_bytes(status.as_u16(), &bytes));
}
serde_json::from_slice(&bytes).map_err(BoxxkiteError::from)
}
pub(crate) async fn send_or_default<T: DeserializeOwned + Default>(
&self,
builder: RequestBuilder,
) -> Result<T, BoxxkiteError> {
let resp = self.execute_with_retry(builder).await?;
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
return Err(api_error_from_bytes(status.as_u16(), &bytes));
}
if bytes.is_empty() {
return Ok(T::default());
}
serde_json::from_slice(&bytes).map_err(BoxxkiteError::from)
}
pub(crate) async fn send_no_content(
&self,
builder: RequestBuilder,
) -> Result<(), BoxxkiteError> {
let resp = self.execute_with_retry(builder).await?;
let status = resp.status();
if !status.is_success() {
let bytes = resp.bytes().await?;
return Err(api_error_from_bytes(status.as_u16(), &bytes));
}
Ok(())
}
}
#[derive(Default)]
pub struct ClientBuilder {
base_url: Option<String>,
api_key: Option<String>,
timeout: Option<Duration>,
http_client: Option<reqwest::Client>,
retry: Option<RetryConfig>,
}
impl ClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
self.http_client = Some(http_client);
self
}
pub fn retry(mut self, retry: RetryConfig) -> Self {
self.retry = Some(retry);
self
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.retry = Some(RetryConfig {
max_retries,
..RetryConfig::default()
});
self
}
pub fn build(self) -> Result<Client, BoxxkiteError> {
let base_url = self
.base_url
.ok_or_else(|| BoxxkiteError::Config("base_url is required".to_string()))?;
let api_key = self
.api_key
.ok_or_else(|| BoxxkiteError::Config("api_key is required".to_string()))?;
validate_base_url_scheme(&base_url)?;
let base_url = base_url.trim_end_matches('/').to_string();
let http = match self.http_client {
Some(client) => client,
None => reqwest::Client::builder()
.timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
.build()
.map_err(BoxxkiteError::from)?,
};
Ok(Client {
http,
base_url,
api_key,
retry: self.retry.unwrap_or_else(RetryConfig::disabled),
})
}
}
#[derive(Clone, Debug)]
pub struct RetryConfig {
pub max_retries: u32,
pub base_delay: Duration,
pub max_delay: Duration,
pub jitter: bool,
pub respect_retry_after: bool,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(30),
jitter: true,
respect_retry_after: true,
}
}
}
impl RetryConfig {
pub fn disabled() -> Self {
Self {
max_retries: 0,
..Self::default()
}
}
pub fn new(max_retries: u32) -> Self {
Self {
max_retries,
..Self::default()
}
}
fn backoff(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
if self.respect_retry_after {
if let Some(after) = retry_after {
return after.min(self.max_delay);
}
}
let factor = 2u32.saturating_pow(attempt);
let raw = self.base_delay.saturating_mul(factor).min(self.max_delay);
if self.jitter {
raw.mul_f64(random_fraction())
} else {
raw
}
}
}
fn is_idempotent(method: &Method) -> bool {
matches!(
*method,
Method::GET | Method::PUT | Method::DELETE | Method::HEAD | Method::OPTIONS | Method::TRACE
)
}
fn should_retry_status(status: StatusCode, idempotent: bool) -> bool {
if status == StatusCode::TOO_MANY_REQUESTS {
return true;
}
status.is_server_error() && idempotent
}
fn parse_retry_after(headers: &header::HeaderMap) -> Option<Duration> {
let raw = headers.get(header::RETRY_AFTER)?.to_str().ok()?;
raw.trim().parse::<u64>().ok().map(Duration::from_secs)
}
fn random_fraction() -> f64 {
use std::hash::{BuildHasher, Hasher};
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u8(0);
(hasher.finish() as f64) / (u64::MAX as f64 + 1.0)
}
fn validate_base_url_scheme(base_url: &str) -> Result<(), BoxxkiteError> {
let parsed = url::Url::parse(base_url)
.map_err(|err| BoxxkiteError::Config(format!("invalid base_url {base_url:?}: {err}")))?;
match parsed.scheme() {
"https" => Ok(()),
"http"
if parsed
.host_str()
.is_some_and(|h| LOCALHOST_HOSTNAMES.contains(&h)) =>
{
Ok(())
}
_ => Err(BoxxkiteError::Config(format!(
"refusing to use non-https base_url {base_url:?}: this would send your API key in \
cleartext. Use an https:// URL, or http://localhost (local dev only)."
))),
}
}
pub(crate) fn to_ws_url(base_url: &str, path: &str) -> String {
if let Some(rest) = base_url.strip_prefix("https://") {
format!("wss://{rest}{path}")
} else if let Some(rest) = base_url.strip_prefix("http://") {
format!("ws://{rest}{path}")
} else {
format!("{base_url}{path}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_plain_http_to_a_remote_host() {
let err = Client::builder()
.base_url("http://cp.example.com")
.api_key("bxk_live_test")
.build()
.unwrap_err();
assert!(matches!(err, BoxxkiteError::Config(_)));
}
#[test]
fn allows_https() {
assert!(Client::builder()
.base_url("https://cp.example.com")
.api_key("bxk_live_test")
.build()
.is_ok());
}
#[test]
fn allows_http_localhost_for_local_dev() {
assert!(Client::builder()
.base_url("http://localhost:8090")
.api_key("bxk_live_test")
.build()
.is_ok());
}
#[test]
fn allows_http_127_0_0_1_for_local_dev() {
assert!(Client::builder()
.base_url("http://127.0.0.1:8090")
.api_key("bxk_live_test")
.build()
.is_ok());
}
#[test]
fn strips_trailing_slash() {
let client = Client::builder()
.base_url("https://cp.example.com/")
.api_key("k")
.build()
.unwrap();
assert_eq!(client.base_url, "https://cp.example.com");
}
#[test]
fn requires_base_url_and_api_key() {
assert!(matches!(
Client::builder().api_key("k").build().unwrap_err(),
BoxxkiteError::Config(_)
));
assert!(matches!(
Client::builder()
.base_url("https://cp.example.com")
.build()
.unwrap_err(),
BoxxkiteError::Config(_)
));
}
#[test]
fn to_ws_url_converts_scheme() {
assert_eq!(
to_ws_url("https://cp.example.com", "/x"),
"wss://cp.example.com/x"
);
assert_eq!(
to_ws_url("http://localhost:8090", "/x"),
"ws://localhost:8090/x"
);
}
#[test]
fn retry_is_disabled_by_default() {
let client = Client::new("https://cp.example.com", "k").unwrap();
assert_eq!(client.retry.max_retries, 0);
}
#[test]
fn max_retries_builder_sets_default_backoff() {
let client = Client::builder()
.base_url("https://cp.example.com")
.api_key("k")
.max_retries(4)
.build()
.unwrap();
assert_eq!(client.retry.max_retries, 4);
assert!(client.retry.jitter);
assert!(client.retry.respect_retry_after);
}
#[test]
fn only_429_and_idempotent_5xx_retry() {
assert!(should_retry_status(StatusCode::TOO_MANY_REQUESTS, false));
assert!(should_retry_status(StatusCode::TOO_MANY_REQUESTS, true));
assert!(should_retry_status(StatusCode::SERVICE_UNAVAILABLE, true));
assert!(!should_retry_status(StatusCode::SERVICE_UNAVAILABLE, false));
assert!(!should_retry_status(StatusCode::BAD_REQUEST, true));
assert!(!should_retry_status(StatusCode::NOT_FOUND, true));
}
#[test]
fn post_is_not_idempotent() {
assert!(!is_idempotent(&Method::POST));
assert!(is_idempotent(&Method::GET));
assert!(is_idempotent(&Method::PUT));
assert!(is_idempotent(&Method::DELETE));
}
#[test]
fn backoff_grows_and_caps_at_max_delay() {
let cfg = RetryConfig {
max_retries: 10,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(4),
jitter: false,
respect_retry_after: true,
};
assert_eq!(cfg.backoff(0, None), Duration::from_secs(1));
assert_eq!(cfg.backoff(1, None), Duration::from_secs(2));
assert_eq!(cfg.backoff(2, None), Duration::from_secs(4));
assert_eq!(cfg.backoff(3, None), Duration::from_secs(4));
assert_eq!(cfg.backoff(64, None), Duration::from_secs(4));
}
#[test]
fn retry_after_overrides_backoff_but_is_capped() {
let cfg = RetryConfig {
max_delay: Duration::from_secs(10),
respect_retry_after: true,
jitter: false,
..RetryConfig::default()
};
assert_eq!(
cfg.backoff(0, Some(Duration::from_secs(3))),
Duration::from_secs(3)
);
assert_eq!(
cfg.backoff(0, Some(Duration::from_secs(999))),
Duration::from_secs(10)
);
}
#[test]
fn parse_retry_after_reads_seconds_form_only() {
let mut headers = header::HeaderMap::new();
headers.insert(header::RETRY_AFTER, "5".parse().unwrap());
assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(5)));
let mut date_headers = header::HeaderMap::new();
date_headers.insert(
header::RETRY_AFTER,
"Wed, 21 Oct 2026 07:28:00 GMT".parse().unwrap(),
);
assert_eq!(parse_retry_after(&date_headers), None);
}
}