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}