Skip to main content

solari/
http.rs

1//! Shared HTTP transport for SDK ⇆ Gateway REST. Owns auth headers, error
2//! mapping, retries/backoff, idempotency keys, and timeouts. Mirrors `http.ts`.
3//!
4//! Retry policy: idempotent requests (GET, DELETE, or any request carrying an
5//! Idempotency-Key) retry on network errors, HTTP 5xx, and bodies flagged
6//! `retryable`, with exponential backoff + jitter. 429 is NOT retried (org at
7//! its session cap). Non-idempotent writes are never silently retried.
8
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use reqwest::Method;
13use serde::de::DeserializeOwned;
14use serde_json::Value;
15
16use crate::error::{map_gateway_error, SolariError};
17use crate::types::GatewayErrorBody;
18
19/// JS `encodeURIComponent`: leaves `A-Za-z0-9 - _ . ! ~ * ' ( )` unescaped,
20/// percent-encodes every other byte as `%XX` over its UTF-8 bytes.
21///
22/// USE THIS FOR EVERY ID INTERPOLATED INTO A PATH. Session ids are
23/// `<poolId>:<vmId>:<orgId>.<sig>`, and this crate previously built paths with a
24/// bare `format!("/sandboxes/{sandbox_id}")` — no escaping at all. That happened
25/// to work (colons are legal path characters, and the id alphabet is otherwise
26/// URL-safe), but it left the id's bytes at the mercy of whatever the gateway
27/// hands back, and it made Rust's URLs differ byte-for-byte from the TypeScript
28/// reference, which the cross-language wire contract test cannot look away from.
29pub(crate) fn encode_uri_component(s: &str) -> String {
30    let mut out = String::with_capacity(s.len());
31    for &b in s.as_bytes() {
32        let c = b as char;
33        if c.is_ascii_alphanumeric()
34            || matches!(c, '-' | '_' | '.' | '!' | '~' | '*' | '\'' | '(' | ')')
35        {
36            out.push(c);
37        } else {
38            out.push('%');
39            out.push_str(&format!("{b:02X}"));
40        }
41    }
42    out
43}
44
45/// Options for constructing an [`HttpTransport`].
46#[derive(Debug, Clone)]
47pub struct HttpOptions {
48    pub api_key: String,
49    pub base_url: String,
50    /// Max retry attempts for idempotent requests. Default 5.
51    pub max_retries: u32,
52    /// Per-request timeout. Default 300s.
53    pub request_timeout: Duration,
54    /// Fixed retry delay. When `None`, uses exponential backoff + jitter
55    /// (mainly for tests: pass `Some(0)` to make retries instant/deterministic).
56    pub retry_delay_ms: Option<u64>,
57}
58
59impl HttpOptions {
60    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
61        HttpOptions {
62            api_key: api_key.into(),
63            base_url: base_url.into(),
64            max_retries: 5,
65            request_timeout: Duration::from_millis(300_000),
66            retry_delay_ms: None,
67        }
68    }
69}
70
71pub struct HttpTransport {
72    api_key: String,
73    base_url: String,
74    client: reqwest::Client,
75    max_retries: u32,
76    retry_delay_ms: Option<u64>,
77}
78
79impl HttpTransport {
80    pub fn new(opts: HttpOptions) -> Result<Self, SolariError> {
81        if opts.api_key.is_empty() {
82            return Err(SolariError::Other("HttpTransport requires an apiKey".into()));
83        }
84        if opts.base_url.is_empty() {
85            return Err(SolariError::Other("HttpTransport requires a baseUrl".into()));
86        }
87        let base_url = opts.base_url.trim_end_matches('/').to_string();
88        let client = reqwest::Client::builder()
89            .timeout(opts.request_timeout)
90            .build()
91            .map_err(|e| SolariError::Other(format!("failed to build HTTP client: {e}")))?;
92        Ok(HttpTransport {
93            api_key: opts.api_key,
94            base_url,
95            client,
96            max_retries: opts.max_retries,
97            retry_delay_ms: opts.retry_delay_ms,
98        })
99    }
100
101    pub fn api_key(&self) -> &str {
102        &self.api_key
103    }
104
105    /// `Authorization` header for handles + WS upgrades.
106    pub fn auth_headers(&self) -> Vec<(String, String)> {
107        vec![("Authorization".into(), format!("Bearer {}", self.api_key))]
108    }
109
110    /// ws:// (or wss://) origin derived from the gateway base URL.
111    pub fn ws_origin(&self) -> String {
112        // Mirrors TS `.replace(/^http/, "ws")`: http→ws, https→wss.
113        self.base_url.replacen("http", "ws", 1)
114    }
115
116    pub async fn request<T: DeserializeOwned>(
117        &self,
118        method: &str,
119        path: &str,
120        body: Option<Value>,
121        idempotency_key: Option<String>,
122    ) -> Result<T, SolariError> {
123        let idempotent =
124            method == "GET" || method == "DELETE" || idempotency_key.is_some();
125        let m = Method::from_bytes(method.as_bytes())
126            .map_err(|e| SolariError::Other(format!("bad method {method}: {e}")))?;
127        let url = format!("{}{}", self.base_url, path);
128        let body_str = body.as_ref().map(|b| serde_json::to_string(b).unwrap());
129
130        let mut attempt: u32 = 0;
131        loop {
132            let mut rb = self
133                .client
134                .request(m.clone(), &url)
135                .header("Authorization", format!("Bearer {}", self.api_key))
136                .header("Accept", "application/json");
137            if let Some(k) = &idempotency_key {
138                rb = rb.header("Idempotency-Key", k);
139            }
140            if let Some(b) = &body_str {
141                rb = rb.header("Content-Type", "application/json").body(b.clone());
142            }
143
144            let resp = match rb.send().await {
145                Ok(r) => r,
146                Err(err) => {
147                    if idempotent && attempt < self.max_retries {
148                        tokio::time::sleep(self.backoff(attempt)).await;
149                        attempt += 1;
150                        continue;
151                    }
152                    return Err(SolariError::connection(format!(
153                        "{method} {path} failed: {err}"
154                    )));
155                }
156            };
157
158            let status = resp.status().as_u16();
159            if !(200..300).contains(&status) {
160                let text = resp.text().await.unwrap_or_default();
161                let err_body: Option<GatewayErrorBody> =
162                    if text.is_empty() { None } else { serde_json::from_str(&text).ok() };
163                // 5xx or an explicit `retryable` hint. NOT 429.
164                let retryable = status >= 500
165                    || err_body.as_ref().and_then(|b| b.retryable).unwrap_or(false);
166                if idempotent && retryable && attempt < self.max_retries {
167                    tokio::time::sleep(self.backoff(attempt)).await;
168                    attempt += 1;
169                    continue;
170                }
171                return Err(map_gateway_error(status, err_body));
172            }
173
174            let text = resp.text().await.unwrap_or_default();
175            if text.is_empty() {
176                return serde_json::from_str("null")
177                    .map_err(|e| SolariError::Other(format!("empty body decode: {e}")));
178            }
179            return serde_json::from_str(&text)
180                .map_err(|e| SolariError::Other(format!("bad response body: {e}")));
181        }
182    }
183
184    fn backoff(&self, attempt: u32) -> Duration {
185        if let Some(ms) = self.retry_delay_ms {
186            return Duration::from_millis(ms);
187        }
188        // 150·2^n capped at 8s, plus rand(0..250)ms jitter. Mirrors http.ts.
189        let base = (150u64.saturating_mul(1u64 << attempt.min(20))).min(8000);
190        Duration::from_millis(base + jitter_ms())
191    }
192}
193
194/// A fresh idempotency key. Not a real UUID (no `uuid` dep) but unique + opaque,
195/// which is all the gateway requires. Format resembles a v4 UUID.
196pub fn new_idempotency_key() -> String {
197    static COUNTER: AtomicU64 = AtomicU64::new(0);
198    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
199    let t = SystemTime::now()
200        .duration_since(UNIX_EPOCH)
201        .map(|d| d.as_nanos() as u64)
202        .unwrap_or(0);
203    let a = t ^ n.rotate_left(17) ^ 0x9E37_79B9_7F4A_7C15;
204    let b = t.rotate_left(31) ^ n.wrapping_mul(0x2545_F491_4F6C_DD1D);
205    format!(
206        "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
207        (a >> 32) as u32,
208        (a >> 16) as u16,
209        (a & 0x0fff) as u16,
210        ((b >> 48) as u16 & 0x3fff) | 0x8000,
211        b & 0xffff_ffff_ffff
212    )
213}
214
215fn jitter_ms() -> u64 {
216    let t = SystemTime::now()
217        .duration_since(UNIX_EPOCH)
218        .map(|d| d.subsec_nanos() as u64)
219        .unwrap_or(0);
220    (t ^ t.rotate_left(13)) % 250
221}