Skip to main content

polymarket_us/
retry.rs

1use std::time::Duration;
2
3/// Configuration for automatic request retries with exponential backoff and jitter.
4///
5/// Only **idempotent** HTTP methods (`GET`, `DELETE`) are retried automatically.
6/// `POST` requests are never retried by default to prevent duplicate order submissions.
7///
8/// # Example
9/// ```rust
10/// use polymarket_us::{PolymarketUsClient, RetryConfig};
11/// use std::time::Duration;
12///
13/// let client = PolymarketUsClient::builder()
14///     .retry(RetryConfig {
15///         max_retries: 5,
16///         initial_backoff: Duration::from_millis(100),
17///         max_backoff: Duration::from_secs(30),
18///         jitter_factor: 0.3,
19///     })
20///     .build()
21///     .unwrap();
22/// ```
23#[derive(Clone, Debug, PartialEq)]
24pub struct RetryConfig {
25    /// Maximum number of retry attempts (0 = no retries). Default: `3`.
26    pub max_retries: u32,
27
28    /// Initial backoff before the first retry. Default: `200ms`.
29    pub initial_backoff: Duration,
30
31    /// Upper bound on backoff after exponential growth. Default: `10s`.
32    pub max_backoff: Duration,
33
34    /// Fraction of the computed backoff added as random jitter (0.0–1.0).
35    /// Prevents thundering-herd retry storms. Default: `0.25`.
36    pub jitter_factor: f64,
37}
38
39impl Default for RetryConfig {
40    fn default() -> Self {
41        Self {
42            max_retries: 3,
43            initial_backoff: Duration::from_millis(200),
44            max_backoff: Duration::from_secs(10),
45            jitter_factor: 0.25,
46        }
47    }
48}
49
50impl RetryConfig {
51    /// Disable retries entirely.
52    pub fn none() -> Self {
53        Self {
54            max_retries: 0,
55            ..Default::default()
56        }
57    }
58
59    /// Aggressive retry settings for resilient workflows.
60    pub fn aggressive() -> Self {
61        Self {
62            max_retries: 5,
63            initial_backoff: Duration::from_millis(100),
64            max_backoff: Duration::from_secs(30),
65            jitter_factor: 0.3,
66        }
67    }
68
69    /// Compute the backoff duration for the given 1-indexed attempt number.
70    ///
71    /// Uses `initial_backoff × 2^(attempt−1)` capped at `max_backoff`,
72    /// plus jitter derived from the subsecond system clock.
73    pub(crate) fn backoff_for(&self, attempt: u32) -> Duration {
74        // Jitter seed from the subsecond clock — avoids pulling in `rand`.
75        let seed = std::time::SystemTime::now()
76            .duration_since(std::time::UNIX_EPOCH)
77            .unwrap_or_default()
78            .subsec_nanos();
79        self.backoff_with_seed(attempt, seed)
80    }
81
82    /// Backoff computation with an explicit jitter seed, so the jitter range can
83    /// be tested without depending on the wall clock.
84    ///
85    /// `seed_nanos` is expected in `0..1_000_000_000` and is normalised against
86    /// that range. Normalising against `u32::MAX` instead would cap jitter at
87    /// roughly 23% of `jitter_factor` rather than spanning it.
88    fn backoff_with_seed(&self, attempt: u32, seed_nanos: u32) -> Duration {
89        const NANOS_PER_SEC: f64 = 1_000_000_000.0;
90
91        let base_ms = self.initial_backoff.as_millis() as f64;
92        let exp = 2_f64.powi(attempt.saturating_sub(1) as i32);
93        let backoff_ms = (base_ms * exp).min(self.max_backoff.as_millis() as f64);
94
95        let jitter_range_ms = backoff_ms * self.jitter_factor.clamp(0.0, 1.0);
96        let fraction = (seed_nanos as f64 / NANOS_PER_SEC).clamp(0.0, 1.0);
97
98        Duration::from_millis((backoff_ms + fraction * jitter_range_ms) as u64)
99    }
100}
101
102/// Returns `true` for HTTP status codes that are safe to retry.
103pub(crate) fn is_retryable_status(status: u16) -> bool {
104    matches!(status, 429 | 500 | 502 | 503 | 504)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn default_config_has_reasonable_values() {
113        let cfg = RetryConfig::default();
114        assert_eq!(cfg.max_retries, 3);
115        assert_eq!(cfg.initial_backoff, Duration::from_millis(200));
116        assert_eq!(cfg.max_backoff, Duration::from_secs(10));
117        assert!((cfg.jitter_factor - 0.25).abs() < f64::EPSILON);
118    }
119
120    #[test]
121    fn none_config_disables_retries() {
122        assert_eq!(RetryConfig::none().max_retries, 0);
123    }
124
125    #[test]
126    fn backoff_grows_exponentially() {
127        let cfg = RetryConfig {
128            max_retries: 5,
129            initial_backoff: Duration::from_millis(100),
130            max_backoff: Duration::from_secs(60),
131            jitter_factor: 0.0,
132        };
133        assert_eq!(cfg.backoff_for(1), Duration::from_millis(100));
134        assert_eq!(cfg.backoff_for(2), Duration::from_millis(200));
135        assert_eq!(cfg.backoff_for(3), Duration::from_millis(400));
136        assert_eq!(cfg.backoff_for(4), Duration::from_millis(800));
137    }
138
139    #[test]
140    fn backoff_caps_at_max() {
141        let cfg = RetryConfig {
142            max_retries: 10,
143            initial_backoff: Duration::from_millis(1000),
144            max_backoff: Duration::from_secs(5),
145            jitter_factor: 0.0,
146        };
147        assert_eq!(cfg.backoff_for(10), Duration::from_secs(5));
148    }
149
150    #[test]
151    fn backoff_with_jitter_is_within_expected_range() {
152        let cfg = RetryConfig {
153            max_retries: 3,
154            initial_backoff: Duration::from_millis(100),
155            max_backoff: Duration::from_secs(60),
156            jitter_factor: 0.25,
157        };
158        let b = cfg.backoff_for(1);
159        // 100ms base + up to 25ms jitter
160        assert!(b >= Duration::from_millis(100));
161        assert!(b <= Duration::from_millis(125));
162    }
163
164    #[test]
165    fn jitter_spans_the_full_configured_range() {
166        let cfg = RetryConfig {
167            max_retries: 3,
168            initial_backoff: Duration::from_millis(1000),
169            max_backoff: Duration::from_secs(60),
170            jitter_factor: 0.5,
171        };
172
173        // A zero seed adds no jitter; a near-maximum seed adds nearly all of it.
174        // Before the fix the top of the range reached only ~1116ms, because the
175        // seed was normalised against u32::MAX rather than one second of nanos.
176        assert_eq!(cfg.backoff_with_seed(1, 0), Duration::from_millis(1000));
177        assert_eq!(
178            cfg.backoff_with_seed(1, 999_999_999),
179            Duration::from_millis(1499)
180        );
181        assert_eq!(
182            cfg.backoff_with_seed(1, 500_000_000),
183            Duration::from_millis(1250)
184        );
185    }
186
187    #[test]
188    fn jitter_never_exceeds_the_configured_factor() {
189        let cfg = RetryConfig {
190            max_retries: 3,
191            initial_backoff: Duration::from_millis(200),
192            max_backoff: Duration::from_secs(60),
193            jitter_factor: 0.25,
194        };
195        for seed in [0, 1, 250_000_000, 999_999_999, u32::MAX] {
196            let b = cfg.backoff_with_seed(1, seed);
197            assert!(b >= Duration::from_millis(200), "seed {seed} underflowed");
198            assert!(b <= Duration::from_millis(250), "seed {seed} overflowed");
199        }
200    }
201
202    #[test]
203    fn retryable_status_codes() {
204        assert!(is_retryable_status(429));
205        assert!(is_retryable_status(500));
206        assert!(is_retryable_status(502));
207        assert!(is_retryable_status(503));
208        assert!(is_retryable_status(504));
209    }
210
211    #[test]
212    fn non_retryable_status_codes() {
213        assert!(!is_retryable_status(200));
214        assert!(!is_retryable_status(400));
215        assert!(!is_retryable_status(401));
216        assert!(!is_retryable_status(403));
217        assert!(!is_retryable_status(404));
218    }
219}