use crate::error::{AppError, AppResult};
use std::time::Duration;
use tokio::time::sleep;
const DEFAULT_MAX_ATTEMPTS: u8 = 3;
const DEFAULT_BACKOFF_BASE_MS: u64 = 1_000;
const DEFAULT_BACKOFF_MAX_MS: u64 = 30_000;
const DEFAULT_RATE_LIMIT_SECS: u64 = 60;
const DEFAULT_RATE_LIMIT_CAP_SECS: u64 = 300;
#[must_use]
pub fn max_attempts() -> u8 {
let resolved = crate::config::tuning_u64_in_range(
"net.retry.max_attempts",
u64::from(DEFAULT_MAX_ATTEMPTS),
1,
10,
);
u8::try_from(resolved).unwrap_or(DEFAULT_MAX_ATTEMPTS)
}
fn backoff_delay(attempt: u8) -> Duration {
let base = crate::config::tuning_u64_in_range(
"net.retry.backoff_base_ms",
DEFAULT_BACKOFF_BASE_MS,
1,
600_000,
);
let ceiling = crate::config::tuning_u64_in_range(
"net.retry.backoff_max_ms",
DEFAULT_BACKOFF_MAX_MS,
1,
3_600_000,
);
let factor = 1_u64.checked_shl(u32::from(attempt)).unwrap_or(u64::MAX);
Duration::from_millis(base.saturating_mul(factor).min(ceiling))
}
fn rate_limit_wait_secs(retry_after_secs: Option<u64>) -> u64 {
let fallback = crate::config::tuning_u64_in_range(
"net.retry.rate_limit_default_secs",
DEFAULT_RATE_LIMIT_SECS,
1,
86_400,
);
let cap = crate::config::tuning_u64_in_range(
"net.retry.rate_limit_cap_secs",
DEFAULT_RATE_LIMIT_CAP_SECS,
1,
86_400,
);
retry_after_secs.unwrap_or(fallback).min(cap)
}
pub async fn retry_with_backoff<F, Fut, T>(mut op: F, max_attempts: u8) -> AppResult<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = AppResult<T>>,
{
for attempt in 0..max_attempts {
match op().await {
Ok(value) => return Ok(value),
Err(e) if !is_retryable(&e) => return Err(e),
Err(e) if attempt + 1 == max_attempts => return Err(e),
Err(AppError::RateLimited {
retry_after_secs, ..
}) => {
let wait = rate_limit_wait_secs(retry_after_secs);
tracing::debug!(
target: "events",
event = "retry",
attempt = attempt + 1,
next_delay_secs = wait,
"rate limited (HTTP 429); honouring Retry-After"
);
sleep(Duration::from_secs(wait)).await;
}
Err(_) => {
let delay = backoff_delay(attempt);
tracing::debug!(
target: "events",
event = "retry",
attempt = attempt + 1,
next_delay_secs = delay.as_secs(),
"transient failure; backing off"
);
sleep(delay).await;
}
}
}
Err(AppError::Internal(
"retry_with_backoff was called with max_attempts = 0".to_string(),
))
}
fn is_retryable(err: &AppError) -> bool {
if crate::provider::is_offline() {
return false;
}
err.retryable()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU8, Ordering};
#[tokio::test(start_paused = true)]
async fn rate_limited_waits_retry_after_seconds() {
let calls = AtomicU8::new(0);
let start = tokio::time::Instant::now();
let result = retry_with_backoff(
|| async {
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
Err(AppError::RateLimited {
provider: "provider-noiz",
retry_after_secs: Some(2),
})
} else {
Ok(42u8)
}
},
3,
)
.await;
assert_eq!(result.expect("second attempt succeeds"), 42);
assert_eq!(calls.load(Ordering::SeqCst), 2);
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(2),
"virtual clock advanced only {elapsed:?}"
);
}
#[tokio::test(start_paused = true)]
async fn rate_limited_without_header_is_definitive() {
let calls = AtomicU8::new(0);
let start = tokio::time::Instant::now();
let result = retry_with_backoff(
|| async {
calls.fetch_add(1, Ordering::SeqCst);
Err::<(), _>(AppError::RateLimited {
provider: "provider-noiz",
retry_after_secs: None,
})
},
3,
)
.await;
assert!(result.is_err(), "a quota with no horizon must not retry");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"the operation was attempted more than once"
);
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(1),
"waited {elapsed:?} for a limit the upstream refused to date"
);
}
#[tokio::test(start_paused = true)]
async fn rate_limited_wait_is_capped_at_300s() {
let calls = AtomicU8::new(0);
let start = tokio::time::Instant::now();
let result = retry_with_backoff(
|| async {
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
Err(AppError::RateLimited {
provider: "provider-noiz",
retry_after_secs: Some(9999),
})
} else {
Ok(())
}
},
3,
)
.await;
assert!(result.is_ok());
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(300) && elapsed < Duration::from_secs(360),
"expected ~300s of virtual wait, got {elapsed:?}"
);
}
#[tokio::test(start_paused = true)]
async fn backoff_doubles_and_stops_at_the_ceiling() {
let calls = AtomicU8::new(0);
let start = tokio::time::Instant::now();
let result: AppResult<()> = retry_with_backoff(
|| async {
calls.fetch_add(1, Ordering::SeqCst);
Err(AppError::Timeout("forced".to_string()))
},
7,
)
.await;
assert!(matches!(result, Err(AppError::Timeout(_))));
assert_eq!(calls.load(Ordering::SeqCst), 7);
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_secs(61) && elapsed < Duration::from_secs(62),
"expected 1+2+4+8+16+30 s of virtual wait, got {elapsed:?}"
);
}
}