Skip to main content

oxigdal_cloud/
retry.rs

1//! Retry logic with exponential backoff and circuit breaker pattern
2//!
3//! This module provides comprehensive retry mechanisms for cloud storage operations,
4//! including exponential backoff, jitter, circuit breaker, retry budgets, and idempotency checks.
5
6use std::time::Duration;
7
8use crate::error::{CloudError, Result, RetryError};
9
10/// Default maximum number of retry attempts
11pub const DEFAULT_MAX_RETRIES: usize = 3;
12
13/// Default initial backoff duration (100ms)
14pub const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
15
16/// Default maximum backoff duration (30 seconds)
17pub const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
18
19/// Default backoff multiplier
20pub const DEFAULT_BACKOFF_MULTIPLIER: f64 = 2.0;
21
22/// Retry strategy configuration
23#[derive(Debug, Clone)]
24pub struct RetryConfig {
25    /// Maximum number of retry attempts
26    pub max_retries: usize,
27    /// Initial backoff duration
28    pub initial_backoff: Duration,
29    /// Maximum backoff duration
30    pub max_backoff: Duration,
31    /// Backoff multiplier for exponential backoff
32    pub backoff_multiplier: f64,
33    /// Whether to add jitter to backoff
34    pub jitter: bool,
35    /// Whether to enable circuit breaker
36    pub circuit_breaker: bool,
37    /// Circuit breaker failure threshold
38    pub circuit_breaker_threshold: usize,
39    /// Circuit breaker timeout duration
40    pub circuit_breaker_timeout: Duration,
41}
42
43impl Default for RetryConfig {
44    fn default() -> Self {
45        Self {
46            max_retries: DEFAULT_MAX_RETRIES,
47            initial_backoff: DEFAULT_INITIAL_BACKOFF,
48            max_backoff: DEFAULT_MAX_BACKOFF,
49            backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
50            jitter: true,
51            circuit_breaker: true,
52            circuit_breaker_threshold: 5,
53            circuit_breaker_timeout: Duration::from_secs(60),
54        }
55    }
56}
57
58impl RetryConfig {
59    /// Creates a new retry configuration
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Sets the maximum number of retries
66    #[must_use]
67    pub fn with_max_retries(mut self, max_retries: usize) -> Self {
68        self.max_retries = max_retries;
69        self
70    }
71
72    /// Sets the initial backoff duration
73    #[must_use]
74    pub fn with_initial_backoff(mut self, duration: Duration) -> Self {
75        self.initial_backoff = duration;
76        self
77    }
78
79    /// Sets the maximum backoff duration
80    #[must_use]
81    pub fn with_max_backoff(mut self, duration: Duration) -> Self {
82        self.max_backoff = duration;
83        self
84    }
85
86    /// Sets the backoff multiplier
87    #[must_use]
88    pub fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
89        self.backoff_multiplier = multiplier;
90        self
91    }
92
93    /// Enables or disables jitter
94    #[must_use]
95    pub fn with_jitter(mut self, jitter: bool) -> Self {
96        self.jitter = jitter;
97        self
98    }
99
100    /// Enables or disables circuit breaker
101    #[must_use]
102    pub fn with_circuit_breaker(mut self, enabled: bool) -> Self {
103        self.circuit_breaker = enabled;
104        self
105    }
106}
107
108/// Circuit breaker state
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum CircuitState {
111    /// Circuit is closed (normal operation)
112    Closed,
113    /// Circuit is open (failures exceeded threshold)
114    Open,
115    /// Circuit is half-open (testing if service recovered)
116    HalfOpen,
117}
118
119/// Circuit breaker for preventing cascading failures
120#[derive(Debug)]
121pub struct CircuitBreaker {
122    /// Current state
123    state: CircuitState,
124    /// Consecutive failure count
125    failure_count: usize,
126    /// Failure threshold
127    threshold: usize,
128    /// Timeout duration
129    timeout: Duration,
130    /// Last failure time
131    last_failure: Option<std::time::Instant>,
132}
133
134impl CircuitBreaker {
135    /// Creates a new circuit breaker
136    #[must_use]
137    pub fn new(threshold: usize, timeout: Duration) -> Self {
138        Self {
139            state: CircuitState::Closed,
140            failure_count: 0,
141            threshold,
142            timeout,
143            last_failure: None,
144        }
145    }
146
147    /// Checks if the circuit breaker allows the operation
148    pub fn allow_request(&mut self) -> Result<()> {
149        match self.state {
150            CircuitState::Closed => Ok(()),
151            CircuitState::Open => {
152                // Check if timeout has elapsed
153                if let Some(last_failure) = self.last_failure {
154                    if last_failure.elapsed() >= self.timeout {
155                        tracing::info!("Circuit breaker transitioning to half-open state");
156                        self.state = CircuitState::HalfOpen;
157                        Ok(())
158                    } else {
159                        Err(CloudError::Retry(RetryError::CircuitBreakerOpen {
160                            message: "Circuit breaker is open".to_string(),
161                        }))
162                    }
163                } else {
164                    Ok(())
165                }
166            }
167            CircuitState::HalfOpen => Ok(()),
168        }
169    }
170
171    /// Records a successful operation
172    pub fn record_success(&mut self) {
173        match self.state {
174            CircuitState::Closed => {
175                self.failure_count = 0;
176            }
177            CircuitState::HalfOpen => {
178                tracing::info!("Circuit breaker transitioning to closed state");
179                self.state = CircuitState::Closed;
180                self.failure_count = 0;
181            }
182            CircuitState::Open => {}
183        }
184    }
185
186    /// Records a failed operation
187    pub fn record_failure(&mut self) {
188        self.failure_count += 1;
189        self.last_failure = Some(std::time::Instant::now());
190
191        if self.failure_count >= self.threshold && self.state != CircuitState::Open {
192            tracing::warn!(
193                "Circuit breaker opening after {} failures",
194                self.failure_count
195            );
196            self.state = CircuitState::Open;
197        }
198    }
199
200    /// Returns the current state of the circuit breaker
201    #[must_use]
202    pub fn state(&self) -> CircuitState {
203        self.state
204    }
205}
206
207/// Retry budget for limiting retry overhead
208#[derive(Debug)]
209pub struct RetryBudget {
210    /// Available retry tokens
211    tokens: usize,
212    /// Maximum tokens
213    max_tokens: usize,
214    /// Token refill rate (tokens per second)
215    refill_rate: f64,
216    /// Last refill time
217    last_refill: std::time::Instant,
218}
219
220impl RetryBudget {
221    /// Creates a new retry budget
222    #[must_use]
223    pub fn new(max_tokens: usize, refill_rate: f64) -> Self {
224        Self {
225            tokens: max_tokens,
226            max_tokens,
227            refill_rate,
228            last_refill: std::time::Instant::now(),
229        }
230    }
231
232    /// Tries to consume a retry token
233    pub fn try_consume(&mut self) -> Result<()> {
234        self.refill();
235
236        if self.tokens > 0 {
237            self.tokens -= 1;
238            Ok(())
239        } else {
240            Err(CloudError::Retry(RetryError::BudgetExhausted {
241                message: "Retry budget exhausted".to_string(),
242            }))
243        }
244    }
245
246    /// Refills tokens based on elapsed time
247    fn refill(&mut self) {
248        let elapsed = self.last_refill.elapsed();
249        let tokens_to_add = (elapsed.as_secs_f64() * self.refill_rate) as usize;
250
251        if tokens_to_add > 0 {
252            self.tokens = (self.tokens + tokens_to_add).min(self.max_tokens);
253            self.last_refill = std::time::Instant::now();
254        }
255    }
256}
257
258/// Backoff calculator for exponential backoff with jitter
259#[derive(Debug)]
260pub struct Backoff {
261    /// Configuration
262    config: RetryConfig,
263    /// Current attempt number
264    attempt: usize,
265    /// Per-instance jitter RNG state (LCG), seeded from process/time/stack
266    /// entropy at construction so independent `Backoff` instances -- and
267    /// independent process restarts -- do not replay identical jitter
268    /// sequences (see `entropy_seed`).
269    rng_state: u64,
270}
271
272impl Backoff {
273    /// Creates a new backoff calculator
274    #[must_use]
275    pub fn new(config: RetryConfig) -> Self {
276        Self {
277            config,
278            attempt: 0,
279            rng_state: Self::entropy_seed(),
280        }
281    }
282
283    /// Derives a per-instance, non-zero LCG seed from process/time/stack
284    /// entropy.
285    ///
286    /// This is deliberately `std`-only (no `rand`/`getrandom` dependency) per
287    /// the Pure Rust / minimal-dependency policy; it does not need to be
288    /// cryptographically secure, only to avoid the two failure modes of the
289    /// previous fixed-zero static seed: (1) the first jitter draw of every
290    /// fresh process being exactly zero, and (2) identical jitter sequences
291    /// being replayed across correlated process restarts (e.g. a fleet-wide
292    /// redeploy), which defeats jitter's anti-thundering-herd purpose.
293    fn entropy_seed() -> u64 {
294        use std::sync::atomic::{AtomicU64, Ordering};
295        use std::time::{SystemTime, UNIX_EPOCH};
296
297        // A per-process monotonic disambiguator. This is *not* the seed by
298        // itself (a predictable counter would be a poor RNG seed on its
299        // own) -- it is mixed with time/pid/stack-address entropy below so
300        // that two `Backoff`s constructed back-to-back in the same process
301        // (faster than the clock's resolution can distinguish) still get
302        // different seeds, while cross-process correlation is still broken
303        // by the time/pid components varying across restarts.
304        static CALL_COUNTER: AtomicU64 = AtomicU64::new(0);
305        let counter = CALL_COUNTER.fetch_add(1, Ordering::Relaxed);
306
307        let nanos = SystemTime::now()
308            .duration_since(UNIX_EPOCH)
309            .map(|d| d.as_nanos() as u64)
310            .unwrap_or(0);
311        let pid = u64::from(std::process::id());
312        // Mix in a stack address for additional entropy across processes
313        // that happen to share a PID namespace view or clock.
314        let stack_marker: u8 = 0;
315        let addr = std::ptr::addr_of!(stack_marker) as u64;
316
317        // splitmix64-style mixing so the combined bits are well distributed
318        // rather than just linearly XORed.
319        let mixed = nanos
320            ^ pid.wrapping_mul(0x9E37_79B9_7F4A_7C15)
321            ^ addr.rotate_left(17)
322            ^ counter.wrapping_mul(0xD6E8_FEB8_6659_FD93);
323        let mut z = mixed.wrapping_add(0x9E37_79B9_7F4A_7C15);
324        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
325        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
326        z ^= z >> 31;
327
328        // Avoid a zero seed: it would reproduce the original bug's
329        // degenerate first LCG output (`0 * 1664525 + 1013904223`, whose
330        // upper 32 bits happen to be zero).
331        if z == 0 { 1 } else { z }
332    }
333
334    /// Advances the per-instance LCG and returns a value in `[0.0, 1.0)`.
335    fn next_random(&mut self) -> f64 {
336        let next = self
337            .rng_state
338            .wrapping_mul(1664525)
339            .wrapping_add(1013904223);
340        self.rng_state = next;
341        (next >> 32) as f64 / u32::MAX as f64
342    }
343
344    /// Calculates the next backoff duration
345    #[must_use]
346    pub fn next(&mut self) -> Duration {
347        let base = self.config.initial_backoff.as_secs_f64().mul_add(
348            self.config.backoff_multiplier.powi(self.attempt as i32),
349            0.0,
350        );
351
352        let backoff = if self.config.jitter {
353            // Add jitter (0% to 50% of base)
354            let jitter_factor = 1.0 + (self.next_random() * 0.5);
355            base * jitter_factor
356        } else {
357            base
358        };
359
360        self.attempt += 1;
361
362        Duration::from_secs_f64(backoff.min(self.config.max_backoff.as_secs_f64()))
363    }
364
365    /// Resets the backoff state
366    ///
367    /// Note: this resets the retry attempt counter but deliberately leaves
368    /// the RNG state untouched, so a reused `Backoff` keeps drawing fresh
369    /// jitter values rather than replaying its sequence from the start.
370    pub fn reset(&mut self) {
371        self.attempt = 0;
372    }
373}
374
375/// Determines if an error is retryable
376#[must_use]
377pub fn is_retryable(error: &CloudError) -> bool {
378    match error {
379        CloudError::Timeout { .. } => true,
380        CloudError::RateLimitExceeded { .. } => true,
381        CloudError::Http(http_error) => match http_error {
382            crate::error::HttpError::Network { .. } => true,
383            crate::error::HttpError::Status { status, .. } => {
384                // Retry on 5xx errors and some 4xx errors
385                matches!(
386                    *status,
387                    500 | 502 | 503 | 504 | 408 | 429 // Server errors and rate limiting
388                )
389            }
390            _ => false,
391        },
392        CloudError::S3(s3_error) => match s3_error {
393            crate::error::S3Error::Sdk { .. } => true,
394            _ => false,
395        },
396        CloudError::Azure(azure_error) => match azure_error {
397            crate::error::AzureError::Sdk { .. } => true,
398            _ => false,
399        },
400        CloudError::Gcs(gcs_error) => match gcs_error {
401            crate::error::GcsError::Sdk { .. } => true,
402            _ => false,
403        },
404        CloudError::Io(_) => true,
405        _ => false,
406    }
407}
408
409/// Retry executor with exponential backoff
410#[cfg(feature = "async")]
411pub struct RetryExecutor {
412    /// Configuration
413    config: RetryConfig,
414    /// Circuit breaker
415    circuit_breaker: Option<CircuitBreaker>,
416    /// Retry budget
417    retry_budget: Option<RetryBudget>,
418}
419
420#[cfg(feature = "async")]
421impl RetryExecutor {
422    /// Creates a new retry executor
423    #[must_use]
424    pub fn new(config: RetryConfig) -> Self {
425        let circuit_breaker = if config.circuit_breaker {
426            Some(CircuitBreaker::new(
427                config.circuit_breaker_threshold,
428                config.circuit_breaker_timeout,
429            ))
430        } else {
431            None
432        };
433
434        let retry_budget = Some(RetryBudget::new(100, 10.0)); // 100 tokens, refill at 10/sec
435
436        Self {
437            config,
438            circuit_breaker,
439            retry_budget,
440        }
441    }
442
443    /// Executes an async operation with retry logic
444    pub async fn execute<F, Fut, T>(&mut self, mut operation: F) -> Result<T>
445    where
446        F: FnMut() -> Fut,
447        Fut: std::future::Future<Output = Result<T>>,
448    {
449        // Check circuit breaker
450        if let Some(ref mut cb) = self.circuit_breaker {
451            cb.allow_request()?;
452        }
453
454        let mut backoff = Backoff::new(self.config.clone());
455        let mut attempts = 0;
456
457        loop {
458            match operation().await {
459                Ok(result) => {
460                    // Success - record and return
461                    if let Some(ref mut cb) = self.circuit_breaker {
462                        cb.record_success();
463                    }
464                    return Ok(result);
465                }
466                Err(error) => {
467                    attempts += 1;
468
469                    // Check if error is retryable
470                    if !is_retryable(&error) {
471                        tracing::warn!("Non-retryable error: {}", error);
472                        return Err(error);
473                    }
474
475                    // Check if we've exceeded max retries
476                    if attempts > self.config.max_retries {
477                        tracing::error!("Max retries ({}) exceeded", self.config.max_retries);
478                        if let Some(ref mut cb) = self.circuit_breaker {
479                            cb.record_failure();
480                        }
481                        return Err(CloudError::Retry(RetryError::MaxRetriesExceeded {
482                            attempts,
483                        }));
484                    }
485
486                    // Check retry budget
487                    if let Some(ref mut budget) = self.retry_budget {
488                        budget.try_consume()?;
489                    }
490
491                    // Calculate backoff and wait
492                    let delay = backoff.next();
493                    tracing::warn!(
494                        "Retry attempt {}/{} after {:?}: {}",
495                        attempts,
496                        self.config.max_retries,
497                        delay,
498                        error
499                    );
500
501                    tokio::time::sleep(delay).await;
502                }
503            }
504        }
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_retry_config_builder() {
514        let config = RetryConfig::new()
515            .with_max_retries(5)
516            .with_initial_backoff(Duration::from_millis(50))
517            .with_backoff_multiplier(3.0)
518            .with_jitter(false);
519
520        assert_eq!(config.max_retries, 5);
521        assert_eq!(config.initial_backoff, Duration::from_millis(50));
522        assert_eq!(config.backoff_multiplier, 3.0);
523        assert!(!config.jitter);
524    }
525
526    #[test]
527    fn test_circuit_breaker_closed() {
528        let mut cb = CircuitBreaker::new(3, Duration::from_secs(60));
529        assert_eq!(cb.state, CircuitState::Closed);
530        assert!(cb.allow_request().is_ok());
531    }
532
533    #[test]
534    fn test_circuit_breaker_opens() {
535        let mut cb = CircuitBreaker::new(3, Duration::from_secs(60));
536
537        // Record 3 failures
538        cb.record_failure();
539        cb.record_failure();
540        cb.record_failure();
541
542        assert_eq!(cb.state, CircuitState::Open);
543        assert!(cb.allow_request().is_err());
544    }
545
546    #[test]
547    fn test_circuit_breaker_half_open() {
548        let mut cb = CircuitBreaker::new(3, Duration::from_millis(10));
549
550        // Open the circuit
551        cb.record_failure();
552        cb.record_failure();
553        cb.record_failure();
554        assert_eq!(cb.state, CircuitState::Open);
555
556        // Wait for timeout
557        std::thread::sleep(Duration::from_millis(20));
558
559        // Should transition to half-open
560        assert!(cb.allow_request().is_ok());
561        assert_eq!(cb.state, CircuitState::HalfOpen);
562
563        // Success should close the circuit
564        cb.record_success();
565        assert_eq!(cb.state, CircuitState::Closed);
566    }
567
568    #[test]
569    fn test_retry_budget() {
570        // Use a high refill rate (100 tokens/sec) so we can sleep only 50ms
571        // instead of seconds. At 100 tokens/sec, 50ms yields ~5 tokens.
572        let mut budget = RetryBudget::new(10, 100.0);
573
574        // Consume 10 tokens
575        for _ in 0..10 {
576            assert!(budget.try_consume().is_ok());
577        }
578
579        // 11th should fail
580        assert!(budget.try_consume().is_err());
581
582        // Wait for refill (50ms at 100 tokens/sec = ~5 tokens)
583        std::thread::sleep(Duration::from_millis(50));
584
585        // Should have refilled
586        assert!(budget.try_consume().is_ok());
587    }
588
589    #[test]
590    fn test_backoff_exponential() {
591        let config = RetryConfig::new()
592            .with_initial_backoff(Duration::from_millis(100))
593            .with_backoff_multiplier(2.0)
594            .with_jitter(false);
595
596        let mut backoff = Backoff::new(config);
597
598        let d1 = backoff.next();
599        let d2 = backoff.next();
600        let d3 = backoff.next();
601
602        assert!(d1 < d2);
603        assert!(d2 < d3);
604    }
605
606    #[test]
607    fn test_backoff_first_jitter_is_not_deterministic_zero() {
608        // Regression test: the old fixed-zero static seed made the very
609        // first jitter draw of every fresh process exactly 0.0 (i.e. no
610        // jitter at all on the first backoff). With a per-instance,
611        // entropy-seeded LCG the first jitter factor should differ from the
612        // no-jitter base virtually always.
613        let config = RetryConfig::new()
614            .with_initial_backoff(Duration::from_millis(1000))
615            .with_backoff_multiplier(1.0)
616            .with_jitter(true);
617        let mut jittered = Backoff::new(config.clone());
618        let jittered_first = jittered.next();
619
620        let mut unjittered = Backoff::new(config.with_jitter(false));
621        let base_first = unjittered.next();
622
623        assert_ne!(
624            jittered_first, base_first,
625            "first jittered backoff should not exactly equal the unjittered base"
626        );
627    }
628
629    #[test]
630    fn test_backoff_independent_instances_diverge() {
631        // Regression test: two independently constructed `Backoff`s
632        // (simulating two process starts) must not replay an identical
633        // jitter sequence -- the old implementation used one process-wide
634        // static seed shared by every instance.
635        let config = RetryConfig::new()
636            .with_initial_backoff(Duration::from_millis(1000))
637            .with_backoff_multiplier(1.0)
638            .with_jitter(true);
639
640        let mut backoff_a = Backoff::new(config.clone());
641        let mut backoff_b = Backoff::new(config);
642
643        let sequence_a: Vec<Duration> = (0..5).map(|_| backoff_a.next()).collect();
644        let sequence_b: Vec<Duration> = (0..5).map(|_| backoff_b.next()).collect();
645
646        assert_ne!(
647            sequence_a, sequence_b,
648            "independently constructed Backoff instances must not produce identical jitter sequences"
649        );
650    }
651
652    #[test]
653    fn test_is_retryable() {
654        let timeout_error = CloudError::Timeout {
655            message: "timeout".to_string(),
656        };
657        assert!(is_retryable(&timeout_error));
658
659        let rate_limit_error = CloudError::RateLimitExceeded {
660            message: "rate limit".to_string(),
661        };
662        assert!(is_retryable(&rate_limit_error));
663
664        let not_found_error = CloudError::NotFound {
665            key: "test".to_string(),
666        };
667        assert!(!is_retryable(&not_found_error));
668    }
669
670    #[cfg(feature = "async")]
671    #[tokio::test]
672    async fn test_retry_executor_success() {
673        use std::sync::atomic::{AtomicUsize, Ordering};
674
675        let config = RetryConfig::new().with_max_retries(3);
676        let mut executor = RetryExecutor::new(config);
677
678        let attempt = std::sync::Arc::new(AtomicUsize::new(0));
679        let attempt_clone = attempt.clone();
680        let result = executor
681            .execute(|| {
682                let attempt = attempt_clone.clone();
683                async move {
684                    let current = attempt.fetch_add(1, Ordering::SeqCst) + 1;
685                    if current < 2 {
686                        Err(CloudError::Timeout {
687                            message: "timeout".to_string(),
688                        })
689                    } else {
690                        Ok(42)
691                    }
692                }
693            })
694            .await;
695
696        assert!(result.is_ok());
697        assert_eq!(result.ok(), Some(42));
698        assert_eq!(attempt.load(Ordering::SeqCst), 2);
699    }
700
701    #[cfg(feature = "async")]
702    #[tokio::test]
703    async fn test_retry_executor_max_retries() {
704        use std::sync::atomic::{AtomicUsize, Ordering};
705
706        let config = RetryConfig::new().with_max_retries(2);
707        let mut executor = RetryExecutor::new(config);
708
709        let attempt = std::sync::Arc::new(AtomicUsize::new(0));
710        let attempt_clone = attempt.clone();
711        let result: Result<i32> = executor
712            .execute(|| {
713                let attempt = attempt_clone.clone();
714                async move {
715                    attempt.fetch_add(1, Ordering::SeqCst);
716                    Err(CloudError::Timeout {
717                        message: "timeout".to_string(),
718                    })
719                }
720            })
721            .await;
722
723        assert!(result.is_err());
724        assert_eq!(attempt.load(Ordering::SeqCst), 3); // Initial + 2 retries
725    }
726}