use std::time::Duration;
use futures::StreamExt;
use once_cell::sync::Lazy;
use reqwest::StatusCode;
use tracing::debug;
use crate::error::{Result, SeerError};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_ATTEMPTS: u32 = 4;
const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
const MAX_BACKOFF: Duration = Duration::from_secs(8);
const MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
const MAX_CT_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
static HTTP_CLIENT: Lazy<Option<reqwest::Client>> = Lazy::new(|| {
reqwest::Client::builder()
.timeout(DEFAULT_TIMEOUT)
.user_agent("seer-domain-tool")
.redirect(reqwest::redirect::Policy::none())
.build()
.ok()
});
fn client() -> Result<&'static reqwest::Client> {
HTTP_CLIENT
.as_ref()
.ok_or_else(|| SeerError::HttpError("failed to initialize HTTP client".into()))
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(crate) enum StatusClass {
Ok,
Retry,
Terminal,
}
pub(crate) fn classify_status(status: StatusCode) -> StatusClass {
if status.is_success() {
StatusClass::Ok
} else if status.is_server_error()
|| status == StatusCode::NOT_FOUND
|| status == StatusCode::TOO_MANY_REQUESTS
{
StatusClass::Retry
} else {
StatusClass::Terminal
}
}
pub(crate) fn looks_like_json(body: &[u8]) -> bool {
matches!(
body.iter().find(|b| !b.is_ascii_whitespace()),
Some(b'[') | Some(b'{')
)
}
fn parse_retry_after(value: &str) -> Option<Duration> {
let secs: u64 = value.trim().parse().ok()?;
Some(Duration::from_secs(secs).min(MAX_RETRY_AFTER))
}
enum FetchOutcome {
Retryable {
err: SeerError,
retry_after: Option<Duration>,
},
Terminal(SeerError),
}
pub(crate) async fn fetch_with_retry(url: &str) -> Result<Vec<u8>> {
let mut last_err: Option<SeerError> = None;
for attempt in 0..MAX_ATTEMPTS {
match fetch_once(url).await {
Ok(body) => return Ok(body),
Err(FetchOutcome::Terminal(e)) => return Err(e),
Err(FetchOutcome::Retryable { err, retry_after }) => {
debug!(
attempt = attempt + 1,
max_attempts = MAX_ATTEMPTS,
error = %err,
"Transient CT log failure, retrying"
);
last_err = Some(err);
if attempt + 1 < MAX_ATTEMPTS {
let backoff = retry_after.unwrap_or_else(|| {
(RETRY_BASE_BACKOFF * 2u32.pow(attempt)).min(MAX_BACKOFF)
});
tokio::time::sleep(backoff).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| {
SeerError::HttpError("CT log query failed with no recorded error".into())
}))
}
async fn fetch_once(url: &str) -> std::result::Result<Vec<u8>, FetchOutcome> {
let response = client()
.map_err(FetchOutcome::Terminal)?
.get(url)
.send()
.await
.map_err(|e| FetchOutcome::Retryable {
err: SeerError::HttpError(format!("CT log query failed: {}", e)),
retry_after: None,
})?;
let status = response.status();
match classify_status(status) {
StatusClass::Ok => {}
class => {
let err = SeerError::HttpError(format!("CT log returned status {}", status));
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(parse_retry_after);
return Err(if class == StatusClass::Retry {
FetchOutcome::Retryable { err, retry_after }
} else {
FetchOutcome::Terminal(err)
});
}
}
if let Some(content_length) = response.content_length() {
if content_length as usize > MAX_CT_RESPONSE_SIZE {
return Err(FetchOutcome::Terminal(SeerError::HttpError(format!(
"CT log response too large: {} bytes (limit: {} bytes)",
content_length, MAX_CT_RESPONSE_SIZE
))));
}
}
let mut body: Vec<u8> = Vec::new();
let mut stream = response.bytes_stream();
let streamed = tokio::time::timeout(DEFAULT_TIMEOUT, async {
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| FetchOutcome::Retryable {
err: SeerError::HttpError(format!("Failed to read CT log response: {}", e)),
retry_after: None,
})?;
if body.len() + chunk.len() > MAX_CT_RESPONSE_SIZE {
return Err(FetchOutcome::Terminal(SeerError::HttpError(format!(
"CT log response too large (exceeds {} bytes)",
MAX_CT_RESPONSE_SIZE
))));
}
body.extend_from_slice(&chunk);
}
Ok(body)
})
.await;
let body = match streamed {
Ok(Ok(body)) => body,
Ok(Err(e)) => return Err(e),
Err(_) => {
return Err(FetchOutcome::Retryable {
err: SeerError::Timeout(format!(
"CT log body read timed out after {:?}",
DEFAULT_TIMEOUT
)),
retry_after: None,
})
}
};
if !looks_like_json(&body) {
return Err(FetchOutcome::Retryable {
err: SeerError::HttpError(
"CT log returned a non-JSON body (likely a transient error page)".into(),
),
retry_after: None,
});
}
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_404_and_429_are_retryable() {
assert_eq!(classify_status(StatusCode::NOT_FOUND), StatusClass::Retry);
assert_eq!(
classify_status(StatusCode::TOO_MANY_REQUESTS),
StatusClass::Retry
);
}
#[test]
fn server_errors_are_retryable() {
assert_eq!(classify_status(StatusCode::BAD_GATEWAY), StatusClass::Retry);
assert_eq!(
classify_status(StatusCode::SERVICE_UNAVAILABLE),
StatusClass::Retry
);
assert_eq!(
classify_status(StatusCode::GATEWAY_TIMEOUT),
StatusClass::Retry
);
}
#[test]
fn genuine_client_errors_are_terminal() {
assert_eq!(
classify_status(StatusCode::BAD_REQUEST),
StatusClass::Terminal
);
assert_eq!(
classify_status(StatusCode::FORBIDDEN),
StatusClass::Terminal
);
}
#[test]
fn success_is_ok() {
assert_eq!(classify_status(StatusCode::OK), StatusClass::Ok);
}
#[test]
fn looks_like_json_accepts_arrays_and_objects() {
assert!(looks_like_json(b"[{\"a\":1}]"));
assert!(looks_like_json(b" \n\t {\"a\":1}"));
}
#[test]
fn looks_like_json_rejects_html_error_pages() {
assert!(!looks_like_json(
b"<html><body>502 Bad Gateway</body></html>"
));
assert!(!looks_like_json(b""));
}
#[test]
fn retry_after_parses_seconds_and_caps() {
assert_eq!(parse_retry_after("0"), Some(Duration::ZERO));
assert_eq!(parse_retry_after(" 5 "), Some(Duration::from_secs(5)));
assert_eq!(parse_retry_after("not-a-number"), None);
assert_eq!(parse_retry_after("99999"), Some(MAX_RETRY_AFTER));
}
}