use std::time::Duration;
use serde_json::{json, Value};
use crate::error::SailError;
use crate::retry::{effective_delay, is_retryable_status, RetryPolicy};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
Post,
Delete,
}
#[derive(Debug, Clone)]
pub enum IdempotencyKey {
Auto,
Suppress,
Explicit(String),
}
pub struct HttpCore {
client: reqwest::Client,
base_url: String,
api_key: String,
}
pub struct RequestSpec {
pub method: Method,
pub path: String,
pub query: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
pub extra_headers: Vec<(String, String)>,
pub timeout: Option<f64>,
pub policy: RetryPolicy,
pub idempotency_key: IdempotencyKey,
}
impl HttpCore {
pub fn new(base_url: &str, api_key: &str) -> Result<HttpCore, SailError> {
if base_url.is_empty() {
return Err(SailError::Config {
message:
"API base URL is empty; set SAIL_API_URL e.g. https://api.sailresearch.com"
.to_string(),
});
}
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| SailError::Internal {
message: format!("failed to build HTTP client: {e}"),
})?;
Ok(HttpCore {
client,
base_url: base_url.to_string(),
api_key: api_key.to_string(),
})
}
pub async fn request(&self, spec: &RequestSpec) -> Result<(u16, Value), SailError> {
let url = self.url(&spec.path, &spec.query);
let headers = self.build_headers(spec);
let max_attempts = spec.policy.max_attempts.max(1);
for attempt in 1..=max_attempts {
let (status, body, resp_headers) = self
.send_once(
spec.method,
&url,
spec.body.as_deref(),
&headers,
spec.timeout,
)
.await;
if !is_retryable_status(status, &resp_headers) || attempt == max_attempts {
return Ok((status, body));
}
let delay = effective_delay(attempt, &spec.policy, &resp_headers);
tracing::warn!(
attempt,
max_attempts,
status,
delay_secs = delay,
path = %spec.path,
"retrying transient HTTP failure"
);
if delay > 0.0 {
tokio::time::sleep(Duration::from_secs_f64(delay)).await;
}
}
unreachable!("retry loop exited without returning")
}
fn url(&self, path: &str, query: &[(String, String)]) -> String {
let mut url = format!("{}{}", self.base_url.trim_end_matches('/'), path);
if !query.is_empty() {
let encoded: String = form_urlencoded::Serializer::new(String::new())
.extend_pairs(query.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.finish();
if !encoded.is_empty() {
let separator = if url.contains('?') { '&' } else { '?' };
url.push(separator);
url.push_str(&encoded);
}
}
url
}
fn build_headers(&self, spec: &RequestSpec) -> Vec<(String, String)> {
let is_post = spec.method == Method::Post;
let mut headers: Vec<(String, String)> = Vec::new();
if !self.api_key.is_empty() {
headers.push((
"Authorization".to_string(),
format!("Bearer {}", self.api_key),
));
}
if is_post {
headers.push(("Content-Type".to_string(), "application/json".to_string()));
}
for (key, value) in &spec.extra_headers {
if key.eq_ignore_ascii_case("authorization")
|| (is_post && key.eq_ignore_ascii_case("content-type"))
{
continue;
}
headers.push((key.clone(), value.clone()));
}
if spec.method == Method::Post {
let existing = headers
.iter()
.position(|(k, _)| k.eq_ignore_ascii_case("Idempotency-Key"));
match (&spec.idempotency_key, existing) {
(IdempotencyKey::Auto, None) => headers.push((
"Idempotency-Key".to_string(),
uuid::Uuid::new_v4().simple().to_string(),
)),
(IdempotencyKey::Auto, Some(_)) => {}
(IdempotencyKey::Suppress, Some(i)) => {
headers.remove(i);
}
(IdempotencyKey::Suppress, None) => {}
(IdempotencyKey::Explicit(value), existing) => {
if let Some(i) = existing {
headers.remove(i);
}
headers.push(("Idempotency-Key".to_string(), value.clone()));
}
}
}
headers
}
async fn send_once(
&self,
method: Method,
url: &str,
body: Option<&[u8]>,
headers: &[(String, String)],
timeout: Option<f64>,
) -> (u16, Value, Vec<(String, String)>) {
let mut req = self.client.request(
match method {
Method::Get => reqwest::Method::GET,
Method::Post => reqwest::Method::POST,
Method::Delete => reqwest::Method::DELETE,
},
url,
);
for (key, value) in headers {
req = req.header(key, value);
}
if let Some(body) = body {
req = req.body(body.to_vec());
}
if let Some(timeout) = timeout {
if let Ok(timeout) = Duration::try_from_secs_f64(timeout.max(0.0)) {
req = req.timeout(timeout);
}
}
let resp = match req.send().await {
Ok(resp) => resp,
Err(e) => return (503, self.error_body(&transport_error_message(&e)), vec![]),
};
let status = resp.status().as_u16();
let resp_headers: Vec<(String, String)> = resp
.headers()
.iter()
.map(|(k, v)| {
(
k.as_str().to_string(),
String::from_utf8_lossy(v.as_bytes()).to_string(),
)
})
.collect();
let body_bytes = match resp.bytes().await {
Ok(bytes) => bytes,
Err(e) => return (503, self.error_body(&transport_error_message(&e)), vec![]),
};
if body_bytes.is_empty() {
return (status, Value::Null, resp_headers);
}
let Ok(decoded) = serde_json::from_slice::<Value>(&body_bytes) else {
let text = String::from_utf8_lossy(&body_bytes);
if status >= 300 {
return (status, self.error_body(&text), resp_headers);
}
return (
503,
self.error_body(&format!(
"malformed JSON in {status} response: {}",
truncate_chars(&text, 200)
)),
resp_headers,
);
};
let decoded = if status >= 400 {
sanitize_json(decoded, &self.api_key)
} else if status >= 300 {
self.error_body(&format!(
"unexpected HTTP {status} response: {}",
truncate_chars(&String::from_utf8_lossy(&body_bytes), 200)
))
} else {
decoded
};
(status, decoded, resp_headers)
}
fn error_body(&self, message: &str) -> Value {
json!({"error": {"message": sanitize_error_message(message, &self.api_key)}})
}
}
pub(crate) fn api_error_message(data: &Value, default: &str) -> String {
data.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or(default)
.to_string()
}
fn transport_error_message(e: &reqwest::Error) -> String {
let kind = if e.is_timeout() {
"request timed out"
} else if e.is_connect() {
"connection failed"
} else {
"transport error"
};
let mut message = e.to_string();
let mut source = std::error::Error::source(e);
while let Some(cause) = source {
message = format!("{message}: {cause}");
source = cause.source();
}
format!("{kind}: {message}")
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
text.chars().take(max_chars).collect()
}
fn sanitize_json(value: Value, api_key: &str) -> Value {
match value {
Value::String(s) => Value::String(sanitize_error_message(&s, api_key)),
Value::Array(items) => Value::Array(
items
.into_iter()
.map(|item| sanitize_json(item, api_key))
.collect(),
),
Value::Object(map) => Value::Object(
map.into_iter()
.map(|(key, item)| (key, sanitize_json(item, api_key)))
.collect(),
),
other => other,
}
}
fn sanitize_error_message(message: &str, api_key: &str) -> String {
if api_key.is_empty() {
return message.to_string();
}
message
.replace(&format!("Bearer {api_key}"), "Bearer [redacted]")
.replace(api_key, "[redacted]")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitizes_api_key_everywhere() {
let body = sanitize_json(
json!({"error": {"message": "bad key Bearer sk_123", "extra": ["sk_123", 7]}}),
"sk_123",
);
assert_eq!(
body,
json!({"error": {"message": "bad key Bearer [redacted]", "extra": ["[redacted]", 7]}})
);
}
#[test]
fn empty_api_key_leaves_message() {
assert_eq!(sanitize_error_message("hello sk_123", ""), "hello sk_123");
}
#[test]
fn truncates_by_chars_not_bytes() {
let text = "Ă©".repeat(300);
assert_eq!(truncate_chars(&text, 200).chars().count(), 200);
}
fn core() -> HttpCore {
HttpCore::new("https://api.example.com", "sk_secret").unwrap()
}
fn spec(method: Method, idempotency_key: IdempotencyKey) -> RequestSpec {
RequestSpec {
method,
path: "/v1/sailboxes".to_string(),
query: Vec::new(),
body: None,
extra_headers: Vec::new(),
timeout: None,
policy: crate::retry::NO_RETRY,
idempotency_key,
}
}
fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
#[test]
fn empty_base_url_is_config_error() {
assert!(matches!(
HttpCore::new("", "sk"),
Err(SailError::Config { .. })
));
}
#[test]
fn post_auto_mints_idempotency_key_once() {
let headers = core().build_headers(&spec(Method::Post, IdempotencyKey::Auto));
let key = header(&headers, "Idempotency-Key").expect("auto mints a key");
assert_eq!(key.len(), 32, "uuid4 simple form is 32 hex chars");
assert_eq!(header(&headers, "Authorization"), Some("Bearer sk_secret"));
assert_eq!(header(&headers, "Content-Type"), Some("application/json"));
}
#[test]
fn post_auto_preserves_caller_supplied_key() {
let mut request = spec(Method::Post, IdempotencyKey::Auto);
request.extra_headers = vec![("Idempotency-Key".to_string(), "caller-key".to_string())];
let headers = core().build_headers(&request);
assert_eq!(header(&headers, "Idempotency-Key"), Some("caller-key"));
}
#[test]
fn suppress_strips_and_explicit_overrides_the_key() {
let mut suppressed = spec(Method::Post, IdempotencyKey::Suppress);
suppressed.extra_headers = vec![("Idempotency-Key".to_string(), "caller-key".to_string())];
assert_eq!(
header(&core().build_headers(&suppressed), "Idempotency-Key"),
None
);
let mut explicit = spec(Method::Post, IdempotencyKey::Explicit("pinned".to_string()));
explicit.extra_headers = vec![("Idempotency-Key".to_string(), "caller-key".to_string())];
assert_eq!(
header(&core().build_headers(&explicit), "Idempotency-Key"),
Some("pinned")
);
}
#[test]
fn get_carries_no_idempotency_key_or_content_type() {
let headers = core().build_headers(&spec(Method::Get, IdempotencyKey::Auto));
assert_eq!(header(&headers, "Idempotency-Key"), None);
assert_eq!(header(&headers, "Content-Type"), None);
assert_eq!(header(&headers, "Authorization"), Some("Bearer sk_secret"));
}
#[test]
fn caller_cannot_override_authorization_or_content_type() {
let mut request = spec(Method::Post, IdempotencyKey::Suppress);
request.extra_headers = vec![
("Authorization".to_string(), "Bearer attacker".to_string()),
("content-type".to_string(), "text/plain".to_string()),
("X-Trace".to_string(), "keep-me".to_string()),
];
let headers = core().build_headers(&request);
assert_eq!(header(&headers, "Authorization"), Some("Bearer sk_secret"));
assert_eq!(header(&headers, "Content-Type"), Some("application/json"));
assert_eq!(header(&headers, "X-Trace"), Some("keep-me"));
}
#[test]
fn url_encodes_query_and_merges_with_existing_separator() {
let core = core();
assert_eq!(
core.url("/v1/sailboxes", &[("a b".to_string(), "x&y".to_string())]),
"https://api.example.com/v1/sailboxes?a+b=x%26y"
);
assert_eq!(
core.url("/v1/x?first=1", &[("second".to_string(), "2".to_string())]),
"https://api.example.com/v1/x?first=1&second=2"
);
assert_eq!(core.url("/v1/x", &[]), "https://api.example.com/v1/x");
}
}