use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use rand::RngExt;
use tracing::{debug, warn};
use super::Result;
static RATE_LIMITER: LazyLock<Mutex<Instant>> = LazyLock::new(|| Mutex::new(Instant::now()));
const RATE_LIMIT_DELAY_MS: u64 = 500;
struct ArchLinuxRateLimiter {
last_request: Instant,
current_backoff_ms: u64,
consecutive_failures: u32,
}
static ARCHLINUX_RATE_LIMITER: LazyLock<Mutex<ArchLinuxRateLimiter>> = LazyLock::new(|| {
Mutex::new(ArchLinuxRateLimiter {
last_request: Instant::now(),
current_backoff_ms: 500, consecutive_failures: 0,
})
});
static ARCHLINUX_REQUEST_SEMAPHORE: LazyLock<std::sync::Arc<tokio::sync::Semaphore>> =
LazyLock::new(|| std::sync::Arc::new(tokio::sync::Semaphore::new(1)));
const ARCHLINUX_BASE_DELAY_MS: u64 = 500; const ARCHLINUX_MAX_BACKOFF_MS: u64 = 60000;
#[derive(Debug, Clone)]
enum CircuitState {
Closed,
Open {
opened_at: Instant,
},
HalfOpen,
}
struct CircuitBreakerState {
state: CircuitState,
recent_outcomes: Vec<bool>,
#[allow(dead_code)]
endpoint_pattern: String,
}
static CIRCUIT_BREAKERS: LazyLock<Mutex<HashMap<String, CircuitBreakerState>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
const CIRCUIT_BREAKER_HISTORY_SIZE: usize = 10;
#[allow(dead_code)]
const CIRCUIT_BREAKER_FAILURE_THRESHOLD: f64 = 0.5;
const CIRCUIT_BREAKER_COOLDOWN_SECS: u64 = 60;
static NETWORK_ERROR_FLAG: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[must_use]
pub fn take_network_error() -> bool {
NETWORK_ERROR_FLAG.swap(false, std::sync::atomic::Ordering::SeqCst)
}
pub(super) fn set_network_error() {
NETWORK_ERROR_FLAG.store(true, std::sync::atomic::Ordering::SeqCst);
}
pub(super) async fn retry_with_backoff<T, E, F, Fut>(
mut operation: F,
max_retries: usize,
) -> std::result::Result<T, E>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = std::result::Result<T, E>>,
{
let mut attempt = 0;
loop {
match operation().await {
Ok(result) => return Ok(result),
Err(e) => {
if attempt >= max_retries {
return Err(e);
}
attempt += 1;
let backoff_secs = 1u64 << (attempt - 1); warn!(
attempt,
max_retries,
backoff_secs,
"network request failed, retrying with exponential backoff"
);
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
}
}
}
}
pub(super) async fn rate_limit() {
let delay_needed = {
let mut last_request = match RATE_LIMITER.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let elapsed = last_request.elapsed();
let min_delay = Duration::from_millis(RATE_LIMIT_DELAY_MS);
let delay = if elapsed < min_delay {
#[allow(clippy::unwrap_used)]
min_delay.checked_sub(elapsed).unwrap()
} else {
Duration::ZERO
};
*last_request = Instant::now();
delay
};
if !delay_needed.is_zero() {
tokio::time::sleep(delay_needed).await;
}
}
const JITTER_MAX_MS: u64 = 500;
pub async fn rate_limit_archlinux() -> tokio::sync::OwnedSemaphorePermit {
let permit = ARCHLINUX_REQUEST_SEMAPHORE
.clone()
.acquire_owned()
.await
.expect("archlinux.org request semaphore should never be closed");
let delay_needed = {
let mut limiter = match ARCHLINUX_RATE_LIMITER.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let elapsed = limiter.last_request.elapsed();
let min_delay = Duration::from_millis(limiter.current_backoff_ms);
let delay = if elapsed < min_delay {
#[allow(clippy::unwrap_used)]
min_delay.checked_sub(elapsed).unwrap()
} else {
Duration::ZERO
};
limiter.last_request = Instant::now();
delay
};
if !delay_needed.is_zero() {
let jitter_ms = rand::rng().random_range(0..=JITTER_MAX_MS);
let delay_with_jitter = delay_needed + Duration::from_millis(jitter_ms);
#[allow(clippy::cast_possible_truncation)]
let delay_ms = delay_needed.as_millis() as u64;
debug!(
delay_ms,
jitter_ms,
total_ms = delay_with_jitter.as_millis(),
"rate limiting archlinux.org request with jitter"
);
tokio::time::sleep(delay_with_jitter).await;
}
permit
}
#[must_use]
pub fn extract_endpoint_pattern(url: &str) -> String {
if let Some(path_start) = url.find("://")
&& let Some(path_pos) = url[path_start + 3..].find('/')
{
let path = &url[path_start + 3 + path_pos..];
if path.contains("/packages/") && path.contains("/json/") {
if let Some(json_pos) = path.find("/json/") {
let base = &path[..json_pos];
if let Some(last_slash) = base.rfind('/') {
return format!("{}/*/json/", &base[..=last_slash]);
}
}
}
if path.starts_with("/feeds/") {
return path.to_string();
}
if path.contains("/news/")
&& !path.ends_with('/')
&& let Some(news_pos) = path.find("/news/")
{
return format!("{}/*", &path[..news_pos + "/news/".len()]);
}
return path.to_string();
}
url.to_string()
}
#[allow(clippy::significant_drop_tightening)]
pub fn check_circuit_breaker(endpoint_pattern: &str) -> Result<()> {
let mut breakers = match CIRCUIT_BREAKERS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let breaker = breakers
.entry(endpoint_pattern.to_string())
.or_insert_with(|| CircuitBreakerState {
state: CircuitState::Closed,
recent_outcomes: Vec::new(),
endpoint_pattern: endpoint_pattern.to_string(),
});
match &breaker.state {
CircuitState::Open { opened_at } => {
let elapsed = opened_at.elapsed();
if elapsed.as_secs() >= CIRCUIT_BREAKER_COOLDOWN_SECS {
breaker.state = CircuitState::HalfOpen;
debug!(
endpoint_pattern,
"circuit breaker transitioning Open → HalfOpen after cooldown"
);
Ok(())
} else {
let remaining = CIRCUIT_BREAKER_COOLDOWN_SECS - elapsed.as_secs();
warn!(
endpoint_pattern,
remaining_secs = remaining,
"circuit breaker is Open, blocking request"
);
Err(format!(
"Circuit breaker is Open for {endpoint_pattern} (cooldown: {remaining}s remaining)"
)
.into())
}
}
CircuitState::HalfOpen | CircuitState::Closed => Ok(()),
}
}
#[allow(clippy::significant_drop_tightening)]
pub fn record_circuit_breaker_outcome(endpoint_pattern: &str, success: bool) {
let mut breakers = match CIRCUIT_BREAKERS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let breaker = breakers
.entry(endpoint_pattern.to_string())
.or_insert_with(|| CircuitBreakerState {
state: CircuitState::Closed,
recent_outcomes: Vec::new(),
endpoint_pattern: endpoint_pattern.to_string(),
});
breaker.recent_outcomes.push(success);
if breaker.recent_outcomes.len() > CIRCUIT_BREAKER_HISTORY_SIZE {
breaker.recent_outcomes.remove(0);
}
if success {
breaker.state = CircuitState::Closed;
if !breaker.recent_outcomes.iter().all(|&x| x) {
debug!(
endpoint_pattern,
"circuit breaker: request succeeded, resetting to Closed"
);
}
} else {
let failure_count = breaker
.recent_outcomes
.iter()
.filter(|&&outcome| !outcome)
.count();
let total_count = breaker.recent_outcomes.len();
if failure_count * 2 >= total_count && total_count >= CIRCUIT_BREAKER_HISTORY_SIZE {
breaker.state = CircuitState::Open {
opened_at: Instant::now(),
};
warn!(
endpoint_pattern,
failure_count,
total = breaker.recent_outcomes.len(),
failure_percentage = (failure_count * 100) / total_count,
"circuit breaker opened due to high failure rate"
);
} else if matches!(breaker.state, CircuitState::HalfOpen) {
breaker.state = CircuitState::Open {
opened_at: Instant::now(),
};
warn!(
endpoint_pattern,
"circuit breaker: HalfOpen test failed, reopening"
);
}
}
}
#[must_use]
pub fn extract_retry_after_from_error(error_msg: &str) -> Option<u64> {
if let Some(start) = error_msg.find("Retry-After: ") {
let after_start = start + "Retry-After: ".len();
let remaining = &error_msg[after_start..];
if let Some(end) = remaining.find('s') {
let seconds_str = &remaining[..end];
return seconds_str.trim().parse::<u64>().ok();
}
}
None
}
pub fn increase_archlinux_backoff(retry_after_seconds: Option<u64>) {
let mut limiter = match ARCHLINUX_RATE_LIMITER.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
limiter.consecutive_failures += 1;
if let Some(retry_after) = retry_after_seconds {
let retry_after_ms = (retry_after * 1000).min(ARCHLINUX_MAX_BACKOFF_MS);
limiter.current_backoff_ms = retry_after_ms;
warn!(
consecutive_failures = limiter.consecutive_failures,
retry_after_seconds = retry_after,
backoff_ms = limiter.current_backoff_ms,
"increased archlinux.org backoff delay using Retry-After header"
);
} else {
limiter.current_backoff_ms = (limiter.current_backoff_ms * 2).min(ARCHLINUX_MAX_BACKOFF_MS);
warn!(
consecutive_failures = limiter.consecutive_failures,
backoff_ms = limiter.current_backoff_ms,
"increased archlinux.org backoff delay"
);
}
}
pub fn reset_archlinux_backoff() {
let mut limiter = match ARCHLINUX_RATE_LIMITER.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if limiter.consecutive_failures > 0 {
debug!(
previous_failures = limiter.consecutive_failures,
previous_backoff_ms = limiter.current_backoff_ms,
"resetting archlinux.org backoff after successful request"
);
}
limiter.current_backoff_ms = ARCHLINUX_BASE_DELAY_MS;
limiter.consecutive_failures = 0;
}