oxcache 0.4.3

A high-performance multi-level cache library for Rust with L1 (memory) and L2 (Redis) caching.
Documentation
// Copyright (c) 2025-2026 Kirky.X
// SPDX-License-Identifier: MIT
//! Retry with exponential backoff for Redis operations.
//!
//! Provides `retry_with_backoff`, a generic async wrapper that retries
//! recoverable operations with exponential delay between attempts.

use crate::error::OxCacheResult;
use crate::infra::metrics::unified::GLOBAL_UNIFIED_METRICS;
use std::future::Future;
use std::time::Duration;

/// Retry an async operation with exponential backoff.
///
/// Only retries when the error is recoverable (`OxCacheError::is_recoverable()`).
/// Delay doubles each attempt: `base_delay × 2^(attempt-1)`.
///
/// # Arguments
///
/// * `operation` - Async closure to execute and potentially retry
/// * `max_retries` - Maximum number of retry attempts (0 = no retries)
/// * `base_delay` - Initial delay between retries (doubles each attempt)
///
/// # Returns
///
/// The operation's result on success, or the last error after all retries exhausted.
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); // 2 failures + 1 success
    }

    #[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); // No retries
    }

    #[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); // initial + 2 retries
    }

    #[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, // No retries
            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), // base_delay=50ms: delays = 50ms, 100ms, 200ms
        )
        .await;

        let elapsed = start.elapsed();
        // Total delay should be at least 50 + 100 = 150ms (2 retries before success on 3rd)
        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() {
        // Large base_delay should not panic — saturating_mul caps at max
        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), // Use small delay; we only verify no panic on backoff calc
        )
        .await;

        // Should succeed on 2nd attempt without panic
        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;

        // Use a local UnifiedMetrics instance to avoid global state interference
        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();

        // Directly test the retry logic by counting invocations
        // (Global metrics are shared across concurrent tests, so we verify
        // the function's behavior through call count instead)
        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);
        // 3 retries occurred (attempts 0, 1, 2 failed; attempt 3 succeeded)
        assert_eq!(call_count.load(Ordering::Relaxed), 4);
        // Verify global metric was incremented by at least 3 (may be more from concurrent tests)
        let after = GLOBAL_UNIFIED_METRICS.get_counters().l2_retry_total;
        assert!(after >= 3, "Expected at least 3 retry metrics, got {}", after);
        let _ = before; // suppress unused warning
    }
}