Skip to main content

crispy_stalker/
backoff.rs

1//! Exponential backoff strategy for HTTP retries.
2//!
3//! Faithfully translated from:
4//! - Python: `sleep_time = backoff_factor * (2 ** (attempt - 1))`
5//! - TypeScript: `STALKER_RETRY_BACKOFF_BASE_MS * Math.pow(2, attempt - 1)`
6
7use std::time::Duration;
8
9/// Configuration for exponential backoff retries.
10#[derive(Debug, Clone)]
11pub struct BackoffConfig {
12    /// Maximum number of retry attempts.
13    pub max_retries: u32,
14
15    /// Base backoff duration (multiplied by `2^(attempt-1)`).
16    pub backoff_factor: Duration,
17
18    /// Maximum backoff duration (cap).
19    pub max_backoff: Duration,
20}
21
22impl Default for BackoffConfig {
23    fn default() -> Self {
24        Self {
25            max_retries: 3,
26            backoff_factor: Duration::from_secs(1),
27            max_backoff: Duration::from_secs(30),
28        }
29    }
30}
31
32impl BackoffConfig {
33    /// Calculate the sleep duration for a given attempt (1-indexed).
34    ///
35    /// Formula: `backoff_factor * 2^(attempt - 1)`, capped at `max_backoff`.
36    ///
37    /// Python: `sleep_time = self.backoff_factor * (2 ** (attempt - 1))`
38    /// TypeScript: `STALKER_RETRY_BACKOFF_BASE_MS * Math.pow(2, attempt - 1)`
39    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
40        if attempt == 0 {
41            return Duration::ZERO;
42        }
43        let multiplier = 2u64.saturating_pow(attempt - 1);
44        #[allow(clippy::cast_possible_truncation)]
45        let capped = multiplier.min(u64::from(u32::MAX)) as u32;
46        let delay = self.backoff_factor.saturating_mul(capped);
47        delay.min(self.max_backoff)
48    }
49
50    /// Whether the given attempt (1-indexed) should be retried.
51    pub fn should_retry(&self, attempt: u32) -> bool {
52        attempt < self.max_retries
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn delay_doubles_each_attempt() {
62        let config = BackoffConfig {
63            max_retries: 5,
64            backoff_factor: Duration::from_secs(1),
65            max_backoff: Duration::from_secs(60),
66        };
67
68        assert_eq!(config.delay_for_attempt(1), Duration::from_secs(1));
69        assert_eq!(config.delay_for_attempt(2), Duration::from_secs(2));
70        assert_eq!(config.delay_for_attempt(3), Duration::from_secs(4));
71        assert_eq!(config.delay_for_attempt(4), Duration::from_secs(8));
72        assert_eq!(config.delay_for_attempt(5), Duration::from_secs(16));
73    }
74
75    #[test]
76    fn delay_capped_at_max_backoff() {
77        let config = BackoffConfig {
78            max_retries: 10,
79            backoff_factor: Duration::from_secs(1),
80            max_backoff: Duration::from_secs(10),
81        };
82
83        // 2^4 = 16, but capped at 10
84        assert_eq!(config.delay_for_attempt(5), Duration::from_secs(10));
85        assert_eq!(config.delay_for_attempt(10), Duration::from_secs(10));
86    }
87
88    #[test]
89    fn delay_zero_for_attempt_zero() {
90        let config = BackoffConfig::default();
91        assert_eq!(config.delay_for_attempt(0), Duration::ZERO);
92    }
93
94    #[test]
95    fn fractional_backoff_factor() {
96        let config = BackoffConfig {
97            max_retries: 3,
98            backoff_factor: Duration::from_millis(500),
99            max_backoff: Duration::from_secs(30),
100        };
101
102        assert_eq!(config.delay_for_attempt(1), Duration::from_millis(500));
103        assert_eq!(config.delay_for_attempt(2), Duration::from_millis(1000));
104        assert_eq!(config.delay_for_attempt(3), Duration::from_millis(2000));
105    }
106
107    #[test]
108    fn should_retry_within_limit() {
109        let config = BackoffConfig {
110            max_retries: 3,
111            ..Default::default()
112        };
113
114        assert!(config.should_retry(1));
115        assert!(config.should_retry(2));
116        assert!(!config.should_retry(3));
117        assert!(!config.should_retry(4));
118    }
119
120    #[test]
121    fn default_config_values() {
122        let config = BackoffConfig::default();
123        assert_eq!(config.max_retries, 3);
124        assert_eq!(config.backoff_factor, Duration::from_secs(1));
125        assert_eq!(config.max_backoff, Duration::from_secs(30));
126    }
127}