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 }
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 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 let base = (150u64.saturating_mul(1u64 << attempt.min(20))).min(8000);
190 Duration::from_millis(base + jitter_ms())
191 }
192}
193
194pub 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}