use std::time::Duration;
use reqwest::Method;
use crate::error::SolariError;
pub(crate) fn redact_url(url: &str) -> String {
match url::Url::parse(url) {
Ok(u) if u.host_str().is_some() => {
let port = u.port().map(|p| format!(":{p}")).unwrap_or_default();
format!(
"{}://{}{}/…",
u.scheme(),
u.host_str().unwrap_or_default(),
port
)
}
_ => "<url>".to_string(),
}
}
pub(crate) fn redact_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
let mut after_sessions = false;
for (i, seg) in path.split('/').enumerate() {
if i > 0 {
out.push('/');
}
if after_sessions && !seg.is_empty() {
let (head, rest) = match seg.find(['?', '#']) {
Some(at) => seg.split_at(at),
None => (seg, ""),
};
let keep: String = head.chars().take(8).collect();
out.push_str(&keep);
if head.chars().count() > 8 {
out.push('…');
}
out.push_str(rest);
} else {
out.push_str(seg);
}
after_sessions = seg == "sessions";
}
out
}
pub(crate) fn scrub_url_from(text: &str, secret_url: &str) -> String {
let t = text.replace(secret_url, &redact_url(secret_url));
t.split_whitespace()
.map(|w| {
let core = w.trim_matches(|c: char| {
matches!(c, '(' | ')' | '"' | '\'' | ',' | ';' | ':' | '<' | '>')
});
if core.contains("://") && core != redact_url(core) {
w.replace(core, &redact_url(core))
} else {
w.to_string()
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[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} {}: {status}", redact_path(path)));
}
Err(e) => {
last_err = Some(format!(
"Solari {method} {}: {}",
redact_path(path),
scrub_url_from(&e.to_string(), &url)
));
}
}
if attempt < max_attempts {
tokio::time::sleep(self.backoff).await;
}
}
Err(SolariError::transport(format!(
"Solari {method} {}: exhausted {} attempts{}",
redact_path(path),
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 {} failed: {}",
redact_url(url),
scrub_url_from(&e.to_string(), url)
))
})?;
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 {} failed: {}",
redact_url(url),
scrub_url_from(&e.to_string(), url)
))
})?;
let status = resp.status().as_u16();
let bytes = resp
.bytes()
.await
.map_err(|e| {
SolariError::transport(format!(
"Solari: reading {} failed: {}",
redact_url(url),
scrub_url_from(&e.to_string(), url)
))
})?;
Ok((status, bytes.to_vec()))
}
}
#[cfg(test)]
mod tests {
use super::{is_retryable_status, is_safe_to_replay, is_terminal, redact_path, redact_url, scrub_url_from, HttpTransport};
use std::time::Duration;
#[test]
fn redact_url_keeps_scheme_host_and_port_only() {
assert_eq!(
redact_url("wss://api.getsolari.com/cdp/SECRETSESSION?token=abc"),
"wss://api.getsolari.com/…"
);
assert_eq!(
redact_url("ws://127.0.0.1:9222/devtools/browser/SECRET"),
"ws://127.0.0.1:9222/…"
);
assert_eq!(
redact_url("https://bucket.s3.amazonaws.com/replay.json?X-Amz-Signature=SECRET"),
"https://bucket.s3.amazonaws.com/…"
);
assert_eq!(redact_url("not a url SECRET"), "<url>");
assert_eq!(redact_url(""), "<url>");
}
#[test]
fn scrub_url_from_removes_the_secret_wherever_the_library_printed_it() {
let url = "wss://api.getsolari.com/cdp/SECRETSESSION";
let out = scrub_url_from(
&format!("IO error: connection refused (url: {url}) while dialling {url}."),
url,
);
assert!(!out.contains("SECRETSESSION"), "{out}");
assert!(out.contains("api.getsolari.com"), "{out}");
let out = scrub_url_from("redirected to https://other.example/path/SECRET2?x=1", url);
assert!(!out.contains("SECRET2"), "{out}");
}
#[tokio::test]
async fn a_failed_presigned_get_does_not_print_the_signed_url() {
let http = HttpTransport::new("slr_live_x".into(), "http://127.0.0.1:1".into(), 1, 1, 500)
.unwrap();
let url = "http://127.0.0.1:1/replay.json?X-Amz-Signature=SIGSECRET";
let err = http
.get_presigned(url, Duration::from_millis(500))
.await
.unwrap_err()
.to_string();
assert!(!err.contains("SIGSECRET"), "{err}");
let err = http.get_presigned_bytes(url).await.unwrap_err().to_string();
assert!(!err.contains("SIGSECRET"), "{err}");
}
#[test]
fn redact_path_cuts_the_session_id() {
assert_eq!(redact_path("/sessions/pool:abc:org:123.SECRETSIGNATURE"), "/sessions/pool:abc…");
assert_eq!(redact_path("/sessions/pool:abc:org:123.SECRET/replay-url"), "/sessions/pool:abc…/replay-url");
assert_eq!(redact_path("/sessions/short"), "/sessions/short");
assert_eq!(redact_path("/sessions"), "/sessions");
assert_eq!(redact_path("/profiles/prof_123456789"), "/profiles/prof_123456789");
}
#[tokio::test]
async fn a_transport_failure_does_not_print_the_session_id() {
let http = HttpTransport::new("slr_live_x".into(), "http://127.0.0.1:1".into(), 1, 1, 500).unwrap();
let err = http.request("GET", "/sessions/pool:abc:org:123.IDSECRET", None).await.unwrap_err().to_string();
assert!(!err.contains("IDSECRET"), "{err}");
assert!(err.contains("/sessions/pool:abc…"), "{err}");
}
#[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));
}
}