Skip to main content

ig_client/model/
retry.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 20/10/25
5******************************************************************************/
6use crate::constants::{
7    DEFAULT_MAX_RETRIES, DEFAULT_RETRY_DELAY_SECS, DEPRECATED_INFINITE_RETRY_CAP,
8    MAX_RETRY_DELAY_SECS,
9};
10use crate::utils::config::get_env_or_none;
11use pretty_simple_display::{DebugPretty, DisplaySimple};
12use serde::{Deserialize, Serialize};
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15/// Configuration for HTTP request retry behavior.
16///
17/// Retry is always finite: when a field is unset the finite defaults
18/// [`DEFAULT_MAX_RETRIES`] and [`DEFAULT_RETRY_DELAY_SECS`] apply. Unbounded
19/// retry was removed in PR #26 and must not be reintroduced.
20#[derive(DebugPretty, DisplaySimple, Clone, Deserialize, Serialize)]
21pub struct RetryConfig {
22    /// Maximum number of retries on transient failures.
23    ///
24    /// `None` means "use [`DEFAULT_MAX_RETRIES`]" — it is never interpreted as
25    /// infinite retries.
26    pub max_retry_count: Option<u32>,
27    /// Base delay in seconds between retries.
28    ///
29    /// `None` means "use [`DEFAULT_RETRY_DELAY_SECS`]". This value is the base
30    /// for exponential backoff, not a fixed per-attempt delay.
31    pub retry_delay_secs: Option<u64>,
32}
33
34impl RetryConfig {
35    /// Creates a new retry configuration with the finite defaults.
36    ///
37    /// Equivalent to [`RetryConfig::default`]: reads `MAX_RETRY_COUNT` /
38    /// `RETRY_DELAY_SECS` from the environment when set, otherwise uses
39    /// [`DEFAULT_MAX_RETRIES`] and [`DEFAULT_RETRY_DELAY_SECS`].
40    #[must_use]
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Deprecated: unbounded retry is banned (PR #26).
46    ///
47    /// Previously this returned a config with infinite retries. It now clamps to
48    /// [`DEPRECATED_INFINITE_RETRY_CAP`] — a large but finite cap — so callers
49    /// keep compiling while never looping forever. Use [`RetryConfig::default`]
50    /// or [`RetryConfig::with_max_retries`] instead.
51    ///
52    /// Uses `RETRY_DELAY_SECS` when set, otherwise [`DEFAULT_RETRY_DELAY_SECS`].
53    #[must_use]
54    #[deprecated(note = "unbounded retry is banned; use a finite RetryConfig")]
55    pub fn infinite() -> Self {
56        Self {
57            max_retry_count: Some(DEPRECATED_INFINITE_RETRY_CAP),
58            retry_delay_secs: get_env_or_none("RETRY_DELAY_SECS"),
59        }
60    }
61
62    /// Creates a new retry configuration with a maximum number of retries.
63    ///
64    /// Uses `RETRY_DELAY_SECS` when set, otherwise [`DEFAULT_RETRY_DELAY_SECS`].
65    #[must_use]
66    pub fn with_max_retries(max_retries: u32) -> Self {
67        Self {
68            max_retry_count: Some(max_retries),
69            retry_delay_secs: get_env_or_none("RETRY_DELAY_SECS"),
70        }
71    }
72
73    /// Deprecated: this constructor left the retry count unbounded.
74    ///
75    /// It now clamps the retry count to [`DEPRECATED_INFINITE_RETRY_CAP`] — a
76    /// large but finite cap — while still applying the custom base delay. Use
77    /// [`RetryConfig::with_max_retries_and_delay`] for an explicit finite cap.
78    #[must_use]
79    #[deprecated(note = "unbounded retry is banned; use a finite RetryConfig")]
80    pub fn with_delay(delay_secs: u64) -> Self {
81        Self {
82            max_retry_count: Some(DEPRECATED_INFINITE_RETRY_CAP),
83            retry_delay_secs: Some(delay_secs),
84        }
85    }
86
87    /// Creates a new retry configuration with both max retries and custom delay.
88    #[must_use]
89    pub fn with_max_retries_and_delay(max_retries: u32, delay_secs: u64) -> Self {
90        Self {
91            max_retry_count: Some(max_retries),
92            retry_delay_secs: Some(delay_secs),
93        }
94    }
95
96    /// Gets the maximum retry count.
97    ///
98    /// Always finite: returns [`DEFAULT_MAX_RETRIES`] when unconfigured. A value
99    /// of `0` means "try once, never retry"; it is never treated as infinite.
100    #[must_use]
101    pub fn max_retries(&self) -> u32 {
102        self.max_retry_count.unwrap_or(DEFAULT_MAX_RETRIES)
103    }
104
105    /// Gets the base retry delay in seconds.
106    ///
107    /// Returns [`DEFAULT_RETRY_DELAY_SECS`] when unconfigured.
108    #[must_use]
109    pub fn delay_secs(&self) -> u64 {
110        self.retry_delay_secs.unwrap_or(DEFAULT_RETRY_DELAY_SECS)
111    }
112
113    /// Computes the backoff delay before the given 0-based retry `attempt`.
114    ///
115    /// Exponential: `base * 2^attempt` seconds, where `base` is
116    /// [`RetryConfig::delay_secs`], saturating at [`MAX_RETRY_DELAY_SECS`]. The
117    /// cap is the policy, so rounding down to it is intentional. A jitter of
118    /// `[0, 25%]` of the delay is added to avoid thundering-herd retries; the
119    /// jitter entropy comes from the current wall-clock subsecond nanoseconds
120    /// (this is jitter, not cryptographic randomness).
121    #[must_use]
122    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
123        backoff_delay(Duration::from_secs(self.delay_secs()), attempt)
124    }
125}
126
127/// Computes an exponential backoff delay with jitter for a given `base` delay
128/// and 0-based `attempt`.
129///
130/// The delay is `base * 2^attempt`, saturating at [`MAX_RETRY_DELAY_SECS`]
131/// (the cap is the policy). Checked arithmetic is used on the exponent so a
132/// large `attempt` cannot overflow; it simply saturates at the cap. A jitter of
133/// `[0, 25%]` of the delay is added, derived from wall-clock subsecond
134/// nanoseconds — this is jitter, not cryptographic randomness.
135#[must_use]
136pub(crate) fn backoff_delay(base: Duration, attempt: u32) -> Duration {
137    let cap = Duration::from_secs(MAX_RETRY_DELAY_SECS);
138
139    // base * 2^attempt. `checked_shl` guards the exponent (a shift >= 64 yields
140    // the max factor); the multiply then saturates and we clamp to `cap_ms`.
141    // Saturating to the cap here is intentional — the cap is the policy.
142    let base_ms = u64::try_from(base.as_millis()).unwrap_or(u64::MAX);
143    let cap_ms = u64::try_from(cap.as_millis()).unwrap_or(u64::MAX);
144
145    let factor = 1u64.checked_shl(attempt).unwrap_or(u64::MAX);
146    let scaled_ms = base_ms.saturating_mul(factor).min(cap_ms);
147
148    // Jitter in [0, 25%] of the (capped) delay. Entropy from wall-clock
149    // subsecond nanoseconds — documented as jitter, not crypto.
150    let nanos = SystemTime::now()
151        .duration_since(UNIX_EPOCH)
152        .map_or(0, |d| u64::from(d.subsec_nanos()));
153    let max_jitter = scaled_ms / 4;
154    let jitter_ms = if max_jitter == 0 {
155        0
156    } else {
157        nanos % (max_jitter + 1)
158    };
159
160    Duration::from_millis(scaled_ms.saturating_add(jitter_ms))
161}
162
163impl Default for RetryConfig {
164    fn default() -> Self {
165        let max_retry_count: Option<u32> = get_env_or_none("MAX_RETRY_COUNT");
166        let retry_delay_secs: Option<u64> = get_env_or_none("RETRY_DELAY_SECS");
167
168        Self {
169            max_retry_count,
170            retry_delay_secs,
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::RetryConfig;
178    use crate::constants::{
179        DEFAULT_MAX_RETRIES, DEFAULT_RETRY_DELAY_SECS, DEPRECATED_INFINITE_RETRY_CAP,
180        MAX_RETRY_DELAY_SECS,
181    };
182    use std::time::Duration;
183
184    #[test]
185    fn test_retry_config_default_is_finite() {
186        // Constructed directly (not via env) so the assertion is deterministic.
187        let config = RetryConfig {
188            max_retry_count: None,
189            retry_delay_secs: None,
190        };
191        assert_eq!(config.max_retries(), DEFAULT_MAX_RETRIES);
192        assert_eq!(config.delay_secs(), DEFAULT_RETRY_DELAY_SECS);
193    }
194
195    #[test]
196    fn test_retry_config_explicit_zero_is_not_infinite() {
197        let config = RetryConfig {
198            max_retry_count: Some(0),
199            retry_delay_secs: None,
200        };
201        // Zero means "try once, never retry" — distinct from the unset default.
202        assert_eq!(config.max_retries(), 0);
203    }
204
205    #[test]
206    fn test_delay_for_attempt_grows_and_caps() {
207        let config = RetryConfig {
208            max_retry_count: None,
209            retry_delay_secs: Some(1), // 1s base keeps the math easy to bound.
210        };
211
212        // Each attempt's delay must sit within [base*2^n, base*2^n * 1.25].
213        for attempt in 0u32..4 {
214            let base_ms: u64 = 1000 * (1u64 << attempt);
215            let expected = base_ms.min(MAX_RETRY_DELAY_SECS * 1000);
216            let got =
217                u64::try_from(config.delay_for_attempt(attempt).as_millis()).unwrap_or(u64::MAX);
218            assert!(
219                got >= expected,
220                "attempt {attempt}: {got} < lower bound {expected}"
221            );
222            let upper = expected + expected / 4;
223            assert!(
224                got <= upper,
225                "attempt {attempt}: {got} > upper bound {upper}"
226            );
227        }
228
229        // Monotonic growth (ignoring jitter) until the cap: compare the
230        // exponential component only via the lower bounds already asserted.
231        let d0 = config.delay_for_attempt(0);
232        let d1 = config.delay_for_attempt(1);
233        let d2 = config.delay_for_attempt(2);
234        // Lower bound of d1 (2000ms) exceeds upper bound of d0 (1250ms), etc.
235        assert!(d1 >= Duration::from_millis(2000) && d0 <= Duration::from_millis(1250));
236        assert!(d2 >= Duration::from_millis(4000) && d1 <= Duration::from_millis(2500));
237    }
238
239    #[test]
240    fn test_delay_for_attempt_saturates_at_cap() {
241        let config = RetryConfig {
242            max_retry_count: None,
243            retry_delay_secs: Some(10),
244        };
245        // A very large attempt must saturate at the cap (+ up to 25% jitter),
246        // never overflow.
247        let got = config.delay_for_attempt(60);
248        let cap = Duration::from_secs(MAX_RETRY_DELAY_SECS);
249        assert!(got >= cap);
250        assert!(got <= cap + cap / 4);
251    }
252
253    #[test]
254    #[allow(deprecated)]
255    fn test_deprecated_infinite_is_finite() {
256        assert_eq!(
257            RetryConfig::infinite().max_retries(),
258            DEPRECATED_INFINITE_RETRY_CAP
259        );
260        assert_eq!(
261            RetryConfig::with_delay(5).max_retries(),
262            DEPRECATED_INFINITE_RETRY_CAP
263        );
264        assert_eq!(RetryConfig::with_delay(5).delay_secs(), 5);
265    }
266}