use crate::error::OxCacheResult;
use crate::infra::metrics::unified::GLOBAL_UNIFIED_METRICS;
use std::future::Future;
use std::time::Duration;
pub(crate) async fn retry_with_backoff<F, Fut, T>(
operation: F,
max_retries: u32,
base_delay: Duration,
) -> OxCacheResult<T>
where
F: Fn() -> Fut + Send + Sync,
Fut: Future<Output = OxCacheResult<T>> + Send,
{
let mut attempt = 0u32;
loop {
match operation().await {
Ok(val) => return Ok(val),
Err(e) if e.is_recoverable() && attempt < max_retries => {
attempt += 1;
GLOBAL_UNIFIED_METRICS.record_l2_retry();
let delay = base_delay.saturating_mul(2u32.saturating_pow(attempt - 1));
tokio::time::sleep(delay).await;
}
Err(e) => return Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::OxCacheError;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
#[tokio::test]
async fn test_success_on_first_attempt() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
let result: OxCacheResult<i32> = retry_with_backoff(
|| {
let cc = cc.clone();
async move {
cc.fetch_add(1, Ordering::Relaxed);
Ok(42)
}
},
3,
Duration::from_millis(10),
)
.await;
assert_eq!(result.unwrap(), 42);
assert_eq!(call_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_retry_on_recoverable_error() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
let result: OxCacheResult<i32> = retry_with_backoff(
{
let cc = cc.clone();
move || {
let cc = cc.clone();
async move {
let n = cc.fetch_add(1, Ordering::Relaxed);
if n < 2 {
Err(OxCacheError::Timeout("transient".to_string()))
} else {
Ok(99)
}
}
}
},
3,
Duration::from_millis(10),
)
.await;
assert_eq!(result.unwrap(), 99);
assert_eq!(call_count.load(Ordering::Relaxed), 3); }
#[tokio::test]
async fn test_no_retry_on_non_recoverable_error() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
let result: OxCacheResult<i32> = retry_with_backoff(
{
let cc = cc.clone();
move || {
let cc = cc.clone();
async move {
cc.fetch_add(1, Ordering::Relaxed);
Err(OxCacheError::NotFound("permanent".to_string()))
}
}
},
3,
Duration::from_millis(10),
)
.await;
assert!(result.is_err());
assert_eq!(call_count.load(Ordering::Relaxed), 1); }
#[tokio::test]
async fn test_max_retries_exhausted() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
let result: OxCacheResult<i32> = retry_with_backoff(
{
let cc = cc.clone();
move || {
let cc = cc.clone();
async move {
cc.fetch_add(1, Ordering::Relaxed);
Err(OxCacheError::Connection("down".to_string()))
}
}
},
2,
Duration::from_millis(10),
)
.await;
assert!(result.is_err());
assert_eq!(call_count.load(Ordering::Relaxed), 3); }
#[tokio::test]
async fn test_zero_retries() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
let result: OxCacheResult<i32> = retry_with_backoff(
{
let cc = cc.clone();
move || {
let cc = cc.clone();
async move {
cc.fetch_add(1, Ordering::Relaxed);
Err(OxCacheError::Timeout("transient".to_string()))
}
}
},
0, Duration::from_millis(10),
)
.await;
assert!(result.is_err());
assert_eq!(call_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_exponential_backoff_timing() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
let start = std::time::Instant::now();
let _: OxCacheResult<i32> = retry_with_backoff(
{
let cc = cc.clone();
move || {
let cc = cc.clone();
async move {
let n = cc.fetch_add(1, Ordering::Relaxed);
if n < 3 {
Err(OxCacheError::Timeout("transient".to_string()))
} else {
Ok(1)
}
}
}
},
3,
Duration::from_millis(50), )
.await;
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_millis(140),
"Expected at least ~150ms, got {:?}",
elapsed
);
}
#[tokio::test]
async fn test_connection_error_is_recoverable() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
let result: OxCacheResult<()> = retry_with_backoff(
{
let cc = cc.clone();
move || {
let cc = cc.clone();
async move {
let n = cc.fetch_add(1, Ordering::Relaxed);
if n == 0 {
Err(OxCacheError::Connection("refused".to_string()))
} else {
Ok(())
}
}
}
},
3,
Duration::from_millis(10),
)
.await;
assert!(result.is_ok());
assert_eq!(call_count.load(Ordering::Relaxed), 2);
}
#[tokio::test]
async fn test_saturating_mul_does_not_panic_on_overflow() {
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
let result: OxCacheResult<i32> = retry_with_backoff(
{
let cc = cc.clone();
move || {
let cc = cc.clone();
async move {
let n = cc.fetch_add(1, Ordering::Relaxed);
if n == 0 {
Err(OxCacheError::Timeout("transient".to_string()))
} else {
Ok(1)
}
}
}
},
1,
Duration::from_secs(1), )
.await;
assert_eq!(result.unwrap(), 1);
assert_eq!(call_count.load(Ordering::Relaxed), 2);
}
#[tokio::test]
async fn test_l2_retry_metric_incremented() {
use crate::infra::metrics::unified::GLOBAL_UNIFIED_METRICS;
let local_metrics = crate::infra::metrics::unified::UnifiedMetrics::new();
let before = local_metrics.get_counters().l2_retry_total;
let call_count = Arc::new(AtomicU32::new(0));
let cc = call_count.clone();
let result: OxCacheResult<i32> = retry_with_backoff(
{
let cc = cc.clone();
move || {
let cc = cc.clone();
async move {
let n = cc.fetch_add(1, Ordering::Relaxed);
if n < 3 {
Err(OxCacheError::Timeout("transient".to_string()))
} else {
Ok(42)
}
}
}
},
5,
Duration::from_millis(1),
)
.await;
assert_eq!(result.unwrap(), 42);
assert_eq!(call_count.load(Ordering::Relaxed), 4);
let after = GLOBAL_UNIFIED_METRICS.get_counters().l2_retry_total;
assert!(after >= 3, "Expected at least 3 retry metrics, got {}", after);
let _ = before; }
}