1use 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
19pub(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#[derive(Debug, Clone)]
47pub struct HttpOptions {
48 pub api_key: String,
49 pub base_url: String,
50 pub max_retries: u32,
52 pub request_timeout: Duration,
54 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 pub fn auth_headers(&self) -> Vec<(String, String)> {
107 vec![("Authorization".into(), format!("Bearer {}", self.api_key))]
108 }
109
110 pub fn ws_origin(&self) -> String {
112 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 } else if matches!(method, "POST" | "PUT" | "PATCH") {
143 rb = rb.header("Content-Length", "0");
146 }
147
148 let resp = match rb.send().await {
149 Ok(r) => r,
150 Err(err) => {
151 if idempotent && attempt < self.max_retries {
152 tokio::time::sleep(self.backoff(attempt)).await;
153 attempt += 1;
154 continue;
155 }
156 return Err(SolariError::connection(format!(
157 "{method} {path} failed: {err}"
158 )));
159 }
160 };
161
162 let status = resp.status().as_u16();
163 if !(200..300).contains(&status) {
164 let text = resp.text().await.unwrap_or_default();
165 let err_body: Option<GatewayErrorBody> =
166 if text.is_empty() { None } else { serde_json::from_str(&text).ok() };
167 let retryable = matches!(status, 502 | 503 | 504 | 507)
177 || err_body.as_ref().and_then(|b| b.retryable).unwrap_or(false);
178 if idempotent && retryable && attempt < self.max_retries {
179 tokio::time::sleep(self.backoff(attempt)).await;
180 attempt += 1;
181 continue;
182 }
183 return Err(map_gateway_error(status, err_body));
184 }
185
186 let text = resp.text().await.unwrap_or_default();
187 if text.is_empty() {
188 return serde_json::from_str("null")
189 .map_err(|e| SolariError::Other(format!("empty body decode: {e}")));
190 }
191 return serde_json::from_str(&text)
192 .map_err(|e| SolariError::Other(format!("bad response body: {e}")));
193 }
194 }
195
196 fn backoff(&self, attempt: u32) -> Duration {
197 if let Some(ms) = self.retry_delay_ms {
198 return Duration::from_millis(ms);
199 }
200 let base = (150u64.saturating_mul(1u64 << attempt.min(20))).min(8000);
202 Duration::from_millis(base + jitter_ms())
203 }
204}
205
206pub fn new_idempotency_key() -> String {
209 static COUNTER: AtomicU64 = AtomicU64::new(0);
210 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
211 let t = SystemTime::now()
212 .duration_since(UNIX_EPOCH)
213 .map(|d| d.as_nanos() as u64)
214 .unwrap_or(0);
215 let a = t ^ n.rotate_left(17) ^ 0x9E37_79B9_7F4A_7C15;
216 let b = t.rotate_left(31) ^ n.wrapping_mul(0x2545_F491_4F6C_DD1D);
217 format!(
218 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
219 (a >> 32) as u32,
220 (a >> 16) as u16,
221 (a & 0x0fff) as u16,
222 ((b >> 48) as u16 & 0x3fff) | 0x8000,
223 b & 0xffff_ffff_ffff
224 )
225}
226
227fn jitter_ms() -> u64 {
228 let t = SystemTime::now()
229 .duration_since(UNIX_EPOCH)
230 .map(|d| d.subsec_nanos() as u64)
231 .unwrap_or(0);
232 (t ^ t.rotate_left(13)) % 250
233}