Skip to main content

boxxkite_client/
client.rs

1//! [`Client`]/[`ClientBuilder`] and the shared request plumbing every other
2//! module in this crate builds on. No behavior lives here beyond
3//! request/response handling -- mirrors `sdk-python`'s `BoxxkiteClient`/
4//! `sdk-js`'s `BoxxkiteClient` in spirit (thin HTTP wrapper, same v1 API).
5
6use 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);
14/// Extra headroom added on top of a caller-supplied `exec`/`http_request`
15/// `timeout` so the HTTP client's own request timeout never fires first and
16/// masks the server's own timeout response -- same constant sdk-python's
17/// `EXEC_TIMEOUT_HEADROOM` / sdk-js's `EXEC_TIMEOUT_HEADROOM_MS` add.
18pub(crate) const EXEC_TIMEOUT_HEADROOM: Duration = Duration::from_secs(15);
19
20const LOCALHOST_HOSTNAMES: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
21
22/// A client for a hosted boxxkite control-plane's `/v1/*` REST API.
23///
24/// Construct one with [`Client::new`] for the common case, or
25/// [`Client::builder`] for control over the timeout or the underlying
26/// `reqwest::Client` (e.g. to point it at a test server). Cheap to clone --
27/// clone freely instead of wrapping in `Arc` yourself.
28#[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    /// Shorthand for `Client::builder().base_url(base_url).api_key(api_key).build()`.
38    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    /// Start building a [`Client`] with non-default options (timeout, a
49    /// preconfigured `reqwest::Client`, etc).
50    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    /// Like [`Client::request`], but authenticates with a caller-supplied
65    /// bearer token instead of this client's `api_key` -- for the one route
66    /// (`resend_verification`) that takes a dashboard-session JWT, a
67    /// different, non-interchangeable credential type from an account API
68    /// key.
69    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    /// Send a built request through the configured retry policy. Every
79    /// [`Client::send`]/[`send_or_default`](Client::send_or_default)/
80    /// [`send_no_content`](Client::send_no_content) call funnels through here,
81    /// so retry (when enabled via [`ClientBuilder::retry`]) applies uniformly.
82    /// Streaming paths (`watch`/`takeover`/`desktop`) build their own
83    /// requests and are deliberately not routed through here.
84    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        // A non-clonable body (streaming) or a disabled policy can't/shouldn't
91        // be retried -- send once and return whatever happens.
92        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    /// Send a request and deserialize a JSON response body. Non-2xx
129    /// responses become `BoxxkiteError::Api`; the body is expected to
130    /// contain valid JSON of shape `T` on success.
131    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    /// Like [`Client::send`], but for endpoints that may return an empty
145    /// (`204`, or otherwise body-less) success response -- e.g. `list_*`
146    /// endpoints that return `null` instead of `[]` when nothing exists.
147    /// Returns the JSON-decoded default value (e.g. an empty `Vec`) when the
148    /// body is empty rather than trying to decode nothing as `T`.
149    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    /// Send a request expecting no meaningful response body (`204 No
166    /// Content`, e.g. `destroy_sandbox`/`delete_image`).
167    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/// Builder for [`Client`]. `base_url` and `api_key` are required; everything
182/// else has a sensible default.
183#[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    /// The control-plane's base URL, e.g. `https://cp.example.com`. Must be
198    /// `https://`, unless the host is `localhost`/`127.0.0.1`/`::1` (local
199    /// dev only) -- every request sends `Authorization: Bearer <api_key>`,
200    /// a full-privilege, long-lived account credential, so an `http://` URL
201    /// to anything else would put it on the wire in cleartext. Trailing
202    /// slashes are stripped.
203    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    /// A boxxkite account API key (`bxk_live_...`).
209    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    /// Per-request timeout. Defaults to 30 seconds. Ignored if
215    /// [`ClientBuilder::http_client`] is also set -- configure the timeout
216    /// on that client instead.
217    pub fn timeout(mut self, timeout: Duration) -> Self {
218        self.timeout = Some(timeout);
219        self
220    }
221
222    /// Supply a preconfigured `reqwest::Client` instead of letting this
223    /// builder construct one -- e.g. one pointed at a `wiremock` mock
224    /// server in tests, or with custom TLS/proxy settings. Authentication
225    /// is still applied per-request via `api_key` regardless.
226    pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
227        self.http_client = Some(http_client);
228        self
229    }
230
231    /// Opt in to automatic retry with exponential backoff for transient
232    /// failures. Off by default -- a call fails on the first non-2xx or
233    /// transport error unless a policy is set here. See [`RetryConfig`] for
234    /// what is (and isn't) retried, and [`RetryConfig::default`] for the
235    /// sensible defaults [`ClientBuilder::max_retries`] applies.
236    pub fn retry(mut self, retry: RetryConfig) -> Self {
237        self.retry = Some(retry);
238        self
239    }
240
241    /// Shorthand for [`ClientBuilder::retry`] with the default backoff
242    /// (500ms base, 30s cap, full jitter, honors `Retry-After`) and just the
243    /// retry count changed. `max_retries(0)` disables retries (the default).
244    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    /// Validate the configuration and construct the [`Client`].
253    ///
254    /// # Errors
255    /// [`BoxxkiteError::Config`] if `base_url`/`api_key` weren't set, or if
256    /// `base_url` isn't a valid `https://` (or localhost-only `http://`) URL.
257    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/// Automatic-retry policy for a [`Client`], set via [`ClientBuilder::retry`].
285///
286/// Retries are **opt-in** (a client built without one never retries) and
287/// deliberately conservative about which failures are safe to repeat:
288/// - `429 Too Many Requests` is retried for **any** method (the server
289///   rejected the request before acting on it; `Retry-After` is honored when
290///   present).
291/// - `5xx` responses and transport errors (timeout/connect/request) are
292///   retried only for **idempotent** methods (`GET`/`PUT`/`DELETE`/`HEAD`/
293///   `OPTIONS`/`TRACE`) -- never for a bare `POST`, which may have partially
294///   applied server-side.
295/// - `4xx` other than `429` is never retried.
296///
297/// Delay grows as `base_delay * 2^attempt`, capped at `max_delay`. With
298/// `jitter` on (the default) the actual sleep is a uniform random draw in
299/// `[0, delay]` ("full jitter"), spreading retries so a fleet of clients
300/// doesn't stampede a recovering server in lockstep.
301#[derive(Clone, Debug)]
302pub struct RetryConfig {
303    /// Maximum number of *additional* attempts after the first. `0` disables
304    /// retrying entirely.
305    pub max_retries: u32,
306    /// First-retry delay; doubles each subsequent attempt.
307    pub base_delay: Duration,
308    /// Upper bound on any single computed delay (also caps a large
309    /// `Retry-After`).
310    pub max_delay: Duration,
311    /// Apply full jitter to each delay.
312    pub jitter: bool,
313    /// Honor a `Retry-After` response header (seconds form) in place of the
314    /// computed backoff, still capped by `max_delay`.
315    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    /// A policy that never retries -- the [`Client`] default.
332    pub fn disabled() -> Self {
333        Self {
334            max_retries: 0,
335            ..Self::default()
336        }
337    }
338
339    /// The default backoff (500ms base, 30s cap, full jitter, honors
340    /// `Retry-After`) with a custom retry count.
341    pub fn new(max_retries: u32) -> Self {
342        Self {
343            max_retries,
344            ..Self::default()
345        }
346    }
347
348    /// Delay before the retry numbered `attempt` (0-indexed: `attempt == 0`
349    /// is the first retry). `retry_after` overrides the computed backoff when
350    /// present and `respect_retry_after` is set.
351    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    // Only the delta-seconds form is handled; the HTTP-date form falls back
383    // to the computed backoff (a bounded overshoot at worst, never a hang).
384    let raw = headers.get(header::RETRY_AFTER)?.to_str().ok()?;
385    raw.trim().parse::<u64>().ok().map(Duration::from_secs)
386}
387
388/// A random `f64` in `[0, 1)` for full-jitter backoff, without pulling in a
389/// `rand` dependency: `RandomState` seeds a fresh SipHash keypair per
390/// instance, so `finish()` differs run to run. Jitter tolerates a
391/// weak/biased source -- this is not, and must not be relied on as, a CSPRNG.
392fn 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
418/// `https://` -> `wss://`, `http://` -> `ws://`. `base_url` has already
419/// passed [`validate_base_url_scheme`], so it's always one of those two.
420pub(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        // Unreachable in practice -- validate_base_url_scheme already
427        // rejects every other scheme before a Client can be constructed.
428        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        // 2^3 = 8s exceeds the 4s cap.
560        assert_eq!(cfg.backoff(3, None), Duration::from_secs(4));
561        // A huge attempt number saturates rather than overflowing.
562        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}