1use std::time::Duration;
7
8use reqwest::{header, Method, RequestBuilder, StatusCode};
9use serde::de::DeserializeOwned;
10
11use crate::error::{api_error_from_bytes, BoxxkiteError};
12
13const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
14pub(crate) const EXEC_TIMEOUT_HEADROOM: Duration = Duration::from_secs(15);
19
20const LOCALHOST_HOSTNAMES: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
21
22#[derive(Clone, Debug)]
29pub struct Client {
30 pub(crate) http: reqwest::Client,
31 pub(crate) base_url: String,
32 pub(crate) api_key: String,
33 pub(crate) retry: RetryConfig,
34}
35
36impl Client {
37 pub fn new(
39 base_url: impl Into<String>,
40 api_key: impl Into<String>,
41 ) -> Result<Self, BoxxkiteError> {
42 ClientBuilder::new()
43 .base_url(base_url)
44 .api_key(api_key)
45 .build()
46 }
47
48 pub fn builder() -> ClientBuilder {
51 ClientBuilder::new()
52 }
53
54 pub(crate) fn url(&self, path: &str) -> String {
55 format!("{}{}", self.base_url, path)
56 }
57
58 pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
59 self.http
60 .request(method, self.url(path))
61 .bearer_auth(&self.api_key)
62 }
63
64 pub(crate) fn request_with_auth(
70 &self,
71 method: Method,
72 path: &str,
73 token: &str,
74 ) -> RequestBuilder {
75 self.http.request(method, self.url(path)).bearer_auth(token)
76 }
77
78 async fn execute_with_retry(
85 &self,
86 builder: RequestBuilder,
87 ) -> Result<reqwest::Response, BoxxkiteError> {
88 let request = builder.build().map_err(BoxxkiteError::from)?;
89
90 if self.retry.max_retries == 0 || request.try_clone().is_none() {
93 return self.http.execute(request).await.map_err(BoxxkiteError::from);
94 }
95
96 let idempotent = is_idempotent(request.method());
97 let mut attempt: u32 = 0;
98 loop {
99 let this = request
100 .try_clone()
101 .expect("request body was clonable at the pre-loop check");
102 match self.http.execute(this).await {
103 Ok(resp) => {
104 let status = resp.status();
105 if attempt < self.retry.max_retries && should_retry_status(status, idempotent) {
106 let retry_after = parse_retry_after(resp.headers());
107 let delay = self.retry.backoff(attempt, retry_after);
108 attempt += 1;
109 tokio::time::sleep(delay).await;
110 continue;
111 }
112 return Ok(resp);
113 }
114 Err(err) => {
115 let transient = err.is_timeout() || err.is_connect() || err.is_request();
116 if attempt < self.retry.max_retries && idempotent && transient {
117 let delay = self.retry.backoff(attempt, None);
118 attempt += 1;
119 tokio::time::sleep(delay).await;
120 continue;
121 }
122 return Err(BoxxkiteError::from(err));
123 }
124 }
125 }
126 }
127
128 pub(crate) async fn send<T: DeserializeOwned>(
132 &self,
133 builder: RequestBuilder,
134 ) -> Result<T, BoxxkiteError> {
135 let resp = self.execute_with_retry(builder).await?;
136 let status = resp.status();
137 let bytes = resp.bytes().await?;
138 if !status.is_success() {
139 return Err(api_error_from_bytes(status.as_u16(), &bytes));
140 }
141 serde_json::from_slice(&bytes).map_err(BoxxkiteError::from)
142 }
143
144 pub(crate) async fn send_or_default<T: DeserializeOwned + Default>(
150 &self,
151 builder: RequestBuilder,
152 ) -> Result<T, BoxxkiteError> {
153 let resp = self.execute_with_retry(builder).await?;
154 let status = resp.status();
155 let bytes = resp.bytes().await?;
156 if !status.is_success() {
157 return Err(api_error_from_bytes(status.as_u16(), &bytes));
158 }
159 if bytes.is_empty() {
160 return Ok(T::default());
161 }
162 serde_json::from_slice(&bytes).map_err(BoxxkiteError::from)
163 }
164
165 pub(crate) async fn send_no_content(
168 &self,
169 builder: RequestBuilder,
170 ) -> Result<(), BoxxkiteError> {
171 let resp = self.execute_with_retry(builder).await?;
172 let status = resp.status();
173 if !status.is_success() {
174 let bytes = resp.bytes().await?;
175 return Err(api_error_from_bytes(status.as_u16(), &bytes));
176 }
177 Ok(())
178 }
179}
180
181#[derive(Default)]
184pub struct ClientBuilder {
185 base_url: Option<String>,
186 api_key: Option<String>,
187 timeout: Option<Duration>,
188 http_client: Option<reqwest::Client>,
189 retry: Option<RetryConfig>,
190}
191
192impl ClientBuilder {
193 pub fn new() -> Self {
194 Self::default()
195 }
196
197 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
204 self.base_url = Some(base_url.into());
205 self
206 }
207
208 pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
210 self.api_key = Some(api_key.into());
211 self
212 }
213
214 pub fn timeout(mut self, timeout: Duration) -> Self {
218 self.timeout = Some(timeout);
219 self
220 }
221
222 pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
227 self.http_client = Some(http_client);
228 self
229 }
230
231 pub fn retry(mut self, retry: RetryConfig) -> Self {
237 self.retry = Some(retry);
238 self
239 }
240
241 pub fn max_retries(mut self, max_retries: u32) -> Self {
245 self.retry = Some(RetryConfig {
246 max_retries,
247 ..RetryConfig::default()
248 });
249 self
250 }
251
252 pub fn build(self) -> Result<Client, BoxxkiteError> {
258 let base_url = self
259 .base_url
260 .ok_or_else(|| BoxxkiteError::Config("base_url is required".to_string()))?;
261 let api_key = self
262 .api_key
263 .ok_or_else(|| BoxxkiteError::Config("api_key is required".to_string()))?;
264 validate_base_url_scheme(&base_url)?;
265 let base_url = base_url.trim_end_matches('/').to_string();
266
267 let http = match self.http_client {
268 Some(client) => client,
269 None => reqwest::Client::builder()
270 .timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
271 .build()
272 .map_err(BoxxkiteError::from)?,
273 };
274
275 Ok(Client {
276 http,
277 base_url,
278 api_key,
279 retry: self.retry.unwrap_or_else(RetryConfig::disabled),
280 })
281 }
282}
283
284#[derive(Clone, Debug)]
302pub struct RetryConfig {
303 pub max_retries: u32,
306 pub base_delay: Duration,
308 pub max_delay: Duration,
311 pub jitter: bool,
313 pub respect_retry_after: bool,
316}
317
318impl Default for RetryConfig {
319 fn default() -> Self {
320 Self {
321 max_retries: 3,
322 base_delay: Duration::from_millis(500),
323 max_delay: Duration::from_secs(30),
324 jitter: true,
325 respect_retry_after: true,
326 }
327 }
328}
329
330impl RetryConfig {
331 pub fn disabled() -> Self {
333 Self {
334 max_retries: 0,
335 ..Self::default()
336 }
337 }
338
339 pub fn new(max_retries: u32) -> Self {
342 Self {
343 max_retries,
344 ..Self::default()
345 }
346 }
347
348 fn backoff(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
352 if self.respect_retry_after {
353 if let Some(after) = retry_after {
354 return after.min(self.max_delay);
355 }
356 }
357 let factor = 2u32.saturating_pow(attempt);
358 let raw = self.base_delay.saturating_mul(factor).min(self.max_delay);
359 if self.jitter {
360 raw.mul_f64(random_fraction())
361 } else {
362 raw
363 }
364 }
365}
366
367fn is_idempotent(method: &Method) -> bool {
368 matches!(
369 *method,
370 Method::GET | Method::PUT | Method::DELETE | Method::HEAD | Method::OPTIONS | Method::TRACE
371 )
372}
373
374fn should_retry_status(status: StatusCode, idempotent: bool) -> bool {
375 if status == StatusCode::TOO_MANY_REQUESTS {
376 return true;
377 }
378 status.is_server_error() && idempotent
379}
380
381fn parse_retry_after(headers: &header::HeaderMap) -> Option<Duration> {
382 let raw = headers.get(header::RETRY_AFTER)?.to_str().ok()?;
385 raw.trim().parse::<u64>().ok().map(Duration::from_secs)
386}
387
388fn random_fraction() -> f64 {
393 use std::hash::{BuildHasher, Hasher};
394 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
395 hasher.write_u8(0);
396 (hasher.finish() as f64) / (u64::MAX as f64 + 1.0)
397}
398
399fn validate_base_url_scheme(base_url: &str) -> Result<(), BoxxkiteError> {
400 let parsed = url::Url::parse(base_url)
401 .map_err(|err| BoxxkiteError::Config(format!("invalid base_url {base_url:?}: {err}")))?;
402 match parsed.scheme() {
403 "https" => Ok(()),
404 "http"
405 if parsed
406 .host_str()
407 .is_some_and(|h| LOCALHOST_HOSTNAMES.contains(&h)) =>
408 {
409 Ok(())
410 }
411 _ => Err(BoxxkiteError::Config(format!(
412 "refusing to use non-https base_url {base_url:?}: this would send your API key in \
413 cleartext. Use an https:// URL, or http://localhost (local dev only)."
414 ))),
415 }
416}
417
418pub(crate) fn to_ws_url(base_url: &str, path: &str) -> String {
421 if let Some(rest) = base_url.strip_prefix("https://") {
422 format!("wss://{rest}{path}")
423 } else if let Some(rest) = base_url.strip_prefix("http://") {
424 format!("ws://{rest}{path}")
425 } else {
426 format!("{base_url}{path}")
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn rejects_plain_http_to_a_remote_host() {
438 let err = Client::builder()
439 .base_url("http://cp.example.com")
440 .api_key("bxk_live_test")
441 .build()
442 .unwrap_err();
443 assert!(matches!(err, BoxxkiteError::Config(_)));
444 }
445
446 #[test]
447 fn allows_https() {
448 assert!(Client::builder()
449 .base_url("https://cp.example.com")
450 .api_key("bxk_live_test")
451 .build()
452 .is_ok());
453 }
454
455 #[test]
456 fn allows_http_localhost_for_local_dev() {
457 assert!(Client::builder()
458 .base_url("http://localhost:8090")
459 .api_key("bxk_live_test")
460 .build()
461 .is_ok());
462 }
463
464 #[test]
465 fn allows_http_127_0_0_1_for_local_dev() {
466 assert!(Client::builder()
467 .base_url("http://127.0.0.1:8090")
468 .api_key("bxk_live_test")
469 .build()
470 .is_ok());
471 }
472
473 #[test]
474 fn strips_trailing_slash() {
475 let client = Client::builder()
476 .base_url("https://cp.example.com/")
477 .api_key("k")
478 .build()
479 .unwrap();
480 assert_eq!(client.base_url, "https://cp.example.com");
481 }
482
483 #[test]
484 fn requires_base_url_and_api_key() {
485 assert!(matches!(
486 Client::builder().api_key("k").build().unwrap_err(),
487 BoxxkiteError::Config(_)
488 ));
489 assert!(matches!(
490 Client::builder()
491 .base_url("https://cp.example.com")
492 .build()
493 .unwrap_err(),
494 BoxxkiteError::Config(_)
495 ));
496 }
497
498 #[test]
499 fn to_ws_url_converts_scheme() {
500 assert_eq!(
501 to_ws_url("https://cp.example.com", "/x"),
502 "wss://cp.example.com/x"
503 );
504 assert_eq!(
505 to_ws_url("http://localhost:8090", "/x"),
506 "ws://localhost:8090/x"
507 );
508 }
509
510 #[test]
511 fn retry_is_disabled_by_default() {
512 let client = Client::new("https://cp.example.com", "k").unwrap();
513 assert_eq!(client.retry.max_retries, 0);
514 }
515
516 #[test]
517 fn max_retries_builder_sets_default_backoff() {
518 let client = Client::builder()
519 .base_url("https://cp.example.com")
520 .api_key("k")
521 .max_retries(4)
522 .build()
523 .unwrap();
524 assert_eq!(client.retry.max_retries, 4);
525 assert!(client.retry.jitter);
526 assert!(client.retry.respect_retry_after);
527 }
528
529 #[test]
530 fn only_429_and_idempotent_5xx_retry() {
531 assert!(should_retry_status(StatusCode::TOO_MANY_REQUESTS, false));
532 assert!(should_retry_status(StatusCode::TOO_MANY_REQUESTS, true));
533 assert!(should_retry_status(StatusCode::SERVICE_UNAVAILABLE, true));
534 assert!(!should_retry_status(StatusCode::SERVICE_UNAVAILABLE, false));
535 assert!(!should_retry_status(StatusCode::BAD_REQUEST, true));
536 assert!(!should_retry_status(StatusCode::NOT_FOUND, true));
537 }
538
539 #[test]
540 fn post_is_not_idempotent() {
541 assert!(!is_idempotent(&Method::POST));
542 assert!(is_idempotent(&Method::GET));
543 assert!(is_idempotent(&Method::PUT));
544 assert!(is_idempotent(&Method::DELETE));
545 }
546
547 #[test]
548 fn backoff_grows_and_caps_at_max_delay() {
549 let cfg = RetryConfig {
550 max_retries: 10,
551 base_delay: Duration::from_secs(1),
552 max_delay: Duration::from_secs(4),
553 jitter: false,
554 respect_retry_after: true,
555 };
556 assert_eq!(cfg.backoff(0, None), Duration::from_secs(1));
557 assert_eq!(cfg.backoff(1, None), Duration::from_secs(2));
558 assert_eq!(cfg.backoff(2, None), Duration::from_secs(4));
559 assert_eq!(cfg.backoff(3, None), Duration::from_secs(4));
561 assert_eq!(cfg.backoff(64, None), Duration::from_secs(4));
563 }
564
565 #[test]
566 fn retry_after_overrides_backoff_but_is_capped() {
567 let cfg = RetryConfig {
568 max_delay: Duration::from_secs(10),
569 respect_retry_after: true,
570 jitter: false,
571 ..RetryConfig::default()
572 };
573 assert_eq!(
574 cfg.backoff(0, Some(Duration::from_secs(3))),
575 Duration::from_secs(3)
576 );
577 assert_eq!(
578 cfg.backoff(0, Some(Duration::from_secs(999))),
579 Duration::from_secs(10)
580 );
581 }
582
583 #[test]
584 fn parse_retry_after_reads_seconds_form_only() {
585 let mut headers = header::HeaderMap::new();
586 headers.insert(header::RETRY_AFTER, "5".parse().unwrap());
587 assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(5)));
588
589 let mut date_headers = header::HeaderMap::new();
590 date_headers.insert(
591 header::RETRY_AFTER,
592 "Wed, 21 Oct 2026 07:28:00 GMT".parse().unwrap(),
593 );
594 assert_eq!(parse_retry_after(&date_headers), None);
595 }
596}