use std::time::Duration;
use reqwest::Method;
use crate::error::SolariError;
#[derive(Debug, Clone)]
pub(crate) struct RawResponse {
pub status: u16,
pub body: String,
}
impl RawResponse {
pub fn ok(&self) -> bool {
(200..300).contains(&self.status)
}
}
#[allow(clippy::manual_range_patterns)]
fn is_retryable_status(status: u16) -> bool {
matches!(status, 502 | 503 | 504 | 507)
}
fn is_safe_to_replay(method: &str, idempotency_key: Option<&str>) -> bool {
is_idempotent_method(method) || idempotency_key.is_some()
}
pub(crate) fn new_idempotency_key() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let seed = {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u64(nanos);
h.finish()
};
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("slr-{nanos:x}-{seed:x}-{n:x}")
}
fn is_idempotent_method(method: &str) -> bool {
matches!(
method.to_ascii_uppercase().as_str(),
"GET" | "HEAD" | "DELETE" | "PUT"
)
}
fn says_retryable(body: &str) -> bool {
#[derive(serde::Deserialize)]
struct Hint {
retryable: Option<bool>,
}
serde_json::from_str::<Hint>(body)
.ok()
.and_then(|h| h.retryable)
.unwrap_or(false)
}
fn is_terminal(status: u16, body: &str) -> bool {
#[derive(serde::Deserialize)]
struct Coded {
code: Option<String>,
retryable: Option<bool>,
}
serde_json::from_str::<Coded>(body).ok().is_some_and(|c| {
c.retryable == Some(false)
|| (status == 503 && c.code.is_some_and(|c| c == "ProxyTierUnavailable"))
})
}
#[derive(Clone)]
pub(crate) struct HttpTransport {
api_key: String,
base_url: String,
client: reqwest::Client,
max_attempts: u32,
backoff: Duration,
timeout: Duration,
}
impl std::fmt::Debug for HttpTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpTransport")
.field("api_key", &"<redacted>")
.field("base_url", &self.base_url)
.field("max_attempts", &self.max_attempts)
.field("backoff", &self.backoff)
.field("timeout", &self.timeout)
.finish()
}
}
impl HttpTransport {
pub fn new(
api_key: String,
base_url: String,
max_attempts: u32,
backoff_ms: u64,
timeout_ms: u64,
) -> Result<Self, SolariError> {
let client = reqwest::Client::builder()
.build()
.map_err(|e| SolariError::config(format!("Solari: failed to build HTTP client: {e}")))?;
Ok(HttpTransport {
api_key,
base_url,
client,
max_attempts: max_attempts.max(1),
backoff: Duration::from_millis(backoff_ms),
timeout: Duration::from_millis(timeout_ms),
})
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn request(
&self,
method: &str,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<RawResponse, SolariError> {
self.request_with_key(method, path, body, None).await
}
pub async fn request_with_key(
&self,
method: &str,
path: &str,
body: Option<&serde_json::Value>,
idempotency_key: Option<&str>,
) -> Result<RawResponse, SolariError> {
self.request_full(method, path, body, idempotency_key, &[], false).await
}
pub async fn request_full(
&self,
method: &str,
path: &str,
body: Option<&serde_json::Value>,
idempotency_key: Option<&str>,
headers: &[(&str, &str)],
no_retry: bool,
) -> Result<RawResponse, SolariError> {
let m = Method::from_bytes(method.as_bytes())
.map_err(|e| SolariError::config(format!("Solari: bad method {method}: {e}")))?;
let url = format!("{}{}", self.base_url, path);
let payload = body.map(|b| serde_json::to_string(b).unwrap_or_else(|_| "null".into()));
let mut last_err: Option<String> = None;
let max_attempts = if no_retry { 1 } else { self.max_attempts };
for attempt in 1..=max_attempts {
let mut rb = self
.client
.request(m.clone(), &url)
.timeout(self.timeout)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json");
if let Some(k) = idempotency_key {
rb = rb.header("Idempotency-Key", k);
}
for (k, v) in headers {
rb = rb.header(*k, *v);
}
if let Some(p) = &payload {
rb = rb.body(p.clone());
} else if matches!(method, "POST" | "PUT" | "PATCH") {
rb = rb.header("Content-Length", "0");
}
match rb.send().await {
Ok(resp) => {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
let retry_this = (is_retryable_status(status) && !is_terminal(status, &body))
|| (is_safe_to_replay(method, idempotency_key)
&& says_retryable(&body));
if (200..300).contains(&status) || !retry_this || attempt == max_attempts {
return Ok(RawResponse { status, body });
}
last_err = Some(format!("Solari {method} {path}: {status}"));
}
Err(e) => {
last_err = Some(format!("Solari {method} {path}: {e}"));
}
}
if attempt < max_attempts {
tokio::time::sleep(self.backoff).await;
}
}
Err(SolariError::transport(format!(
"Solari {method} {path}: exhausted {} attempts{}",
max_attempts,
last_err.map(|e| format!(" ({e})")).unwrap_or_default(),
)))
}
pub async fn get_presigned(
&self,
url: &str,
timeout: Duration,
) -> Result<RawResponse, SolariError> {
let resp = self
.client
.get(url)
.timeout(timeout)
.send()
.await
.map_err(|e| SolariError::transport(format!("Solari: GET {url} failed: {e}")))?;
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
Ok(RawResponse { status, body })
}
pub async fn get_presigned_bytes(&self, url: &str) -> Result<(u16, Vec<u8>), SolariError> {
let resp = self
.client
.get(url)
.timeout(self.timeout)
.send()
.await
.map_err(|e| SolariError::transport(format!("Solari: GET {url} failed: {e}")))?;
let status = resp.status().as_u16();
let bytes = resp
.bytes()
.await
.map_err(|e| SolariError::transport(format!("Solari: reading {url} failed: {e}")))?;
Ok((status, bytes.to_vec()))
}
}
#[cfg(test)]
mod tests {
use super::{is_retryable_status, is_safe_to_replay, is_terminal};
#[test]
fn retryable_status_includes_507() {
for s in [502u16, 503, 504, 507] {
assert!(is_retryable_status(s), "expected {s} retryable");
}
for s in [400u16, 429, 500, 501] {
assert!(!is_retryable_status(s), "expected {s} NOT retryable");
}
}
#[test]
fn terminal_responses_are_not_retried_but_ordinary_ones_are() {
let tier = r#"{"error":"Requested proxy tier 'residential' is unavailable","code":"ProxyTierUnavailable","tier":"residential"}"#;
assert!(is_terminal(503, tier));
assert!(!is_terminal(503, r#"{"error":"No stealth pool available","kind":"stealth"}"#));
assert!(!is_terminal(503, "not json"));
assert!(!is_terminal(502, tier));
let unparseable = r#"{"error":"Pool returned an invalid response","code":"PoolResponseUnparseable","retryable":false}"#;
assert!(is_terminal(502, unparseable));
assert!(!is_terminal(502, r#"{"error":"bad gateway"}"#));
assert!(!is_terminal(502, r#"{"retryable":true}"#));
}
#[test]
fn a_post_is_replayable_only_with_a_key() {
assert!(!is_safe_to_replay("POST", None));
assert!(is_safe_to_replay("POST", Some("k")));
assert!(is_safe_to_replay("GET", None));
}
}