use base64::engine::general_purpose;
use base64::prelude::Engine as _;
use reqwest::header::HeaderValue;
use reqwest::Client;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use crate::types::{HttpVersion, HybridCachePayload};
static CACHE_CLIENT: OnceLock<Client> = OnceLock::new();
static CLIENT_SOURCE: AtomicU8 = AtomicU8::new(CLIENT_UNSET);
const CLIENT_UNSET: u8 = 0;
const CLIENT_INJECTED: u8 = 1;
const CLIENT_DEFAULT: u8 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientSource {
Injected,
Default,
}
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_millis(250);
pub const DEFAULT_POOL_MAX_IDLE_PER_HOST: usize = 32;
static CACHE_ENDPOINT: OnceLock<String> = OnceLock::new();
pub fn set_client(client: Client) {
if !try_set_client(client) {
tracing::warn!(
"spider_remote_cache::set_client ignored: a {:?} client is already active",
client_source()
);
}
}
pub fn try_set_client(client: Client) -> bool {
let installed = CACHE_CLIENT.set(client).is_ok();
if installed {
CLIENT_SOURCE.store(CLIENT_INJECTED, Ordering::Release);
}
installed
}
pub fn client_source() -> Option<ClientSource> {
match CLIENT_SOURCE.load(Ordering::Acquire) {
CLIENT_INJECTED => Some(ClientSource::Injected),
CLIENT_DEFAULT => Some(ClientSource::Default),
_ => None,
}
}
pub fn set_endpoint(endpoint: String) {
let _ = CACHE_ENDPOINT.set(endpoint);
}
pub fn default_client_builder() -> reqwest::ClientBuilder {
Client::builder()
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.timeout(Duration::from_millis(
crate::worker::default_timeout_ms().max(1),
))
.pool_max_idle_per_host(DEFAULT_POOL_MAX_IDLE_PER_HOST)
.pool_idle_timeout(Duration::from_secs(90))
.tcp_nodelay(true)
}
pub fn get_client() -> &'static Client {
CACHE_CLIENT.get_or_init(|| {
CLIENT_SOURCE.store(CLIENT_DEFAULT, Ordering::Release);
default_client_builder()
.build()
.expect("failed to build default remote cache client")
})
}
pub fn get_endpoint() -> &'static str {
CACHE_ENDPOINT
.get_or_init(|| {
std::env::var("HYBRID_CACHE_ENDPOINT")
.unwrap_or_else(|_| "http://127.0.0.1:8080".to_string())
})
.as_str()
}
pub fn resolve_base_url(dump_remote: Option<&str>) -> &str {
match dump_remote {
Some(remote) if remote != "true" => remote.trim_ascii(),
_ => get_endpoint(),
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_payload(
cache_key: &str,
url_str: &str,
body: &[u8],
method: &str,
status: u16,
request_headers: &HashMap<String, String>,
response_headers: &HashMap<String, String>,
http_version: &HttpVersion,
) -> HybridCachePayload {
let website_key = url::Url::parse(url_str)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()));
let body_base64 = general_purpose::STANDARD.encode(body);
HybridCachePayload {
website_key,
resource_key: cache_key.to_string(),
url: url_str.to_string(),
method: method.to_string(),
status,
http_version: *http_version,
request_headers: request_headers.clone(),
response_headers: response_headers.clone(),
body_base64,
}
}
#[inline]
pub(crate) fn stamp_site(payload: &mut HybridCachePayload, cache_site: &str) {
if !cache_site.is_empty() {
payload.website_key = Some(cache_site.to_string());
}
}
#[inline]
fn site_header(cache_site: &str) -> HeaderValue {
HeaderValue::from_str(cache_site).unwrap_or(HeaderValue::from_static(""))
}
fn retry_jitter() -> Duration {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
Duration::from_millis(10 + u64::from(nanos % 40))
}
pub(crate) enum PostOutcome {
Ok,
Http(reqwest::StatusCode),
Send(reqwest::Error),
}
pub(crate) async fn send_with_connect_retry<F>(
build: F,
) -> Result<reqwest::Response, reqwest::Error>
where
F: Fn() -> reqwest::RequestBuilder,
{
match build().send().await {
Err(err) if err.is_connect() => {
tokio::time::sleep(retry_jitter()).await;
build().send().await
}
other => other,
}
}
async fn post_json<T: serde::Serialize + ?Sized>(
endpoint: &str,
body: &T,
cache_site: &str,
) -> PostOutcome {
let client = get_client();
let started = Instant::now();
let result = send_with_connect_retry(|| {
client
.post(endpoint)
.json(body)
.header("x-cache-site", site_header(cache_site))
})
.await;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
let outcome = match result {
Ok(resp) if resp.status().is_success() => PostOutcome::Ok,
Ok(resp) => PostOutcome::Http(resp.status()),
Err(err) => PostOutcome::Send(err),
};
let label = match &outcome {
PostOutcome::Ok => crate::metrics::outcome::OK,
PostOutcome::Http(_) => crate::metrics::outcome::HTTP_ERROR,
PostOutcome::Send(_) => crate::metrics::outcome::SEND_ERROR,
};
crate::metrics::upload(label, elapsed_ms);
outcome
}
#[allow(clippy::too_many_arguments)]
pub async fn dump_to_remote(
cache_key: &str,
cache_site: &str,
url_str: &str,
body: &[u8],
method: &str,
status: u16,
request_headers: &HashMap<String, String>,
response_headers: &HashMap<String, String>,
http_version: &HttpVersion,
dump_remote: Option<&str>,
) {
let mut payload = build_payload(
cache_key,
url_str,
body,
method,
status,
request_headers,
response_headers,
http_version,
);
stamp_site(&mut payload, cache_site);
let base_url = resolve_base_url(dump_remote);
let endpoint = format!("{}/cache/index", base_url);
match post_json(&endpoint, &payload, cache_site).await {
PostOutcome::Ok => {
tracing::debug!("remote cache dump: stored {}", cache_key);
}
PostOutcome::Http(status) => {
tracing::warn!(
"remote cache dump: non-success status for {}: {}",
cache_key,
status
);
}
PostOutcome::Send(err) => {
tracing::warn!(
"remote cache dump: failed to POST {} to {}: {}",
cache_key,
endpoint,
err
);
}
}
}
pub async fn dump_site_batch_to_remote(
mut payloads: Vec<HybridCachePayload>,
cache_site: &str,
base_url: &str,
) {
if payloads.is_empty() {
return;
}
for p in payloads.iter_mut() {
stamp_site(p, cache_site);
}
let endpoint = format!("{}/cache/index/batch", base_url);
match post_json(&endpoint, &payloads, cache_site).await {
PostOutcome::Ok => {
tracing::debug!(
"remote cache batch dump: stored {} items for site {}",
payloads.len(),
cache_site
);
}
PostOutcome::Http(status) => {
tracing::warn!(
"remote cache batch dump: non-success for {} items: {}",
payloads.len(),
status
);
}
PostOutcome::Send(err) => {
tracing::warn!(
"remote cache batch dump: failed to POST {} items to {}: {}",
payloads.len(),
endpoint,
err
);
}
}
}
pub async fn dump_batch_to_remote(payloads: Vec<HybridCachePayload>, base_url: &str) {
for (site, group) in group_by_site(payloads) {
dump_site_batch_to_remote(group, &site, base_url).await;
}
}
fn group_by_site(payloads: Vec<HybridCachePayload>) -> Vec<(String, Vec<HybridCachePayload>)> {
let mut groups: Vec<(String, Vec<HybridCachePayload>)> = Vec::new();
for p in payloads {
let site = p.website_key.clone().unwrap_or_default();
match groups.iter_mut().find(|(s, _)| *s == site) {
Some((_, g)) => g.push(p),
None => groups.push((site, vec![p])),
}
}
groups
}
#[cfg(test)]
mod tests {
use super::*;
fn payload(site: Option<&str>, key: &str) -> HybridCachePayload {
HybridCachePayload {
website_key: site.map(str::to_string),
resource_key: key.to_string(),
url: "https://example.com/".into(),
method: "GET".into(),
..Default::default()
}
}
#[test]
fn group_by_site_keeps_sites_apart_and_order() {
let groups = group_by_site(vec![
payload(Some("a"), "1"),
payload(Some("b"), "2"),
payload(Some("a"), "3"),
payload(None, "4"),
]);
let shape: Vec<(String, Vec<String>)> = groups
.into_iter()
.map(|(s, g)| (s, g.into_iter().map(|p| p.resource_key).collect()))
.collect();
assert_eq!(
shape,
vec![
("a".to_string(), vec!["1".to_string(), "3".to_string()]),
("b".to_string(), vec!["2".to_string()]),
(String::new(), vec!["4".to_string()]),
]
);
}
#[test]
fn stamp_site_overrides_host_but_not_with_empty() {
let mut p = payload(Some("example.com"), "k");
stamp_site(&mut p, "");
assert_eq!(p.website_key.as_deref(), Some("example.com"));
stamp_site(&mut p, "abc123");
assert_eq!(p.website_key.as_deref(), Some("abc123"));
}
#[tokio::test]
async fn connect_error_retries_once() {
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let url = format!("http://127.0.0.1:{port}/cache/index");
let client = Client::new();
let calls = std::sync::atomic::AtomicUsize::new(0);
let res = send_with_connect_retry(|| {
calls.fetch_add(1, Ordering::SeqCst);
client.post(&url)
})
.await;
assert!(res.unwrap_err().is_connect());
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn error_after_send_is_not_retried() {
use tokio::io::AsyncReadExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut s, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf).await;
drop(s);
}
});
let url = format!("http://{addr}/cache/index");
let client = Client::new();
let calls = std::sync::atomic::AtomicUsize::new(0);
let res = send_with_connect_retry(|| {
calls.fetch_add(1, Ordering::SeqCst);
client.post(&url).body("x")
})
.await;
let err = res.unwrap_err();
assert!(!err.is_connect(), "{err:?}");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}