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        let base_ms = self.initial_backoff.as_millis() as f64;
75        let exp = 2_f64.powi(attempt.saturating_sub(1) as i32);
76        let backoff_ms = (base_ms * exp).min(self.max_backoff.as_millis() as f64);
77
78        // Deterministic jitter from subsecond clock — avoids pulling in `rand`.
79        let jitter_range_ms = backoff_ms * self.jitter_factor.clamp(0.0, 1.0);
80        let seed = std::time::SystemTime::now()
81            .duration_since(std::time::UNIX_EPOCH)
82            .unwrap_or_default()
83            .subsec_nanos();
84        let jitter_ms = (seed as f64 / u32::MAX as f64) * jitter_range_ms;
85
86        Duration::from_millis((backoff_ms + jitter_ms) as u64)
87    }
88}
89
90/// Returns `true` for HTTP status codes that are safe to retry.
91pub(crate) fn is_retryable_status(status: u16) -> bool {
92    matches!(status, 429 | 500 | 502 | 503 | 504)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn default_config_has_reasonable_values() {
101        let cfg = RetryConfig::default();
102        assert_eq!(cfg.max_retries, 3);
103        assert_eq!(cfg.initial_backoff, Duration::from_millis(200));
104        assert_eq!(cfg.max_backoff, Duration::from_secs(10));
105        assert!((cfg.jitter_factor - 0.25).abs() < f64::EPSILON);
106    }
107
108    #[test]
109    fn none_config_disables_retries() {
110        assert_eq!(RetryConfig::none().max_retries, 0);
111    }
112
113    #[test]
114    fn backoff_grows_exponentially() {
115        let cfg = RetryConfig {
116            max_retries: 5,
117            initial_backoff: Duration::from_millis(100),
118            max_backoff: Duration::from_secs(60),
119            jitter_factor: 0.0,
120        };
121        assert_eq!(cfg.backoff_for(1), Duration::from_millis(100));
122        assert_eq!(cfg.backoff_for(2), Duration::from_millis(200));
123        assert_eq!(cfg.backoff_for(3), Duration::from_millis(400));
124        assert_eq!(cfg.backoff_for(4), Duration::from_millis(800));
125    }
126
127    #[test]
128    fn backoff_caps_at_max() {
129        let cfg = RetryConfig {
130            max_retries: 10,
131            initial_backoff: Duration::from_millis(1000),
132            max_backoff: Duration::from_secs(5),
133            jitter_factor: 0.0,
134        };
135        assert_eq!(cfg.backoff_for(10), Duration::from_secs(5));
136    }
137
138    #[test]
139    fn backoff_with_jitter_is_within_expected_range() {
140        let cfg = RetryConfig {
141            max_retries: 3,
142            initial_backoff: Duration::from_millis(100),
143            max_backoff: Duration::from_secs(60),
144            jitter_factor: 0.25,
145        };
146        let b = cfg.backoff_for(1);
147        // 100ms base + up to 25ms jitter
148        assert!(b >= Duration::from_millis(100));
149        assert!(b <= Duration::from_millis(125));
150    }
151
152    #[test]
153    fn retryable_status_codes() {
154        assert!(is_retryable_status(429));
155        assert!(is_retryable_status(500));
156        assert!(is_retryable_status(502));
157        assert!(is_retryable_status(503));
158        assert!(is_retryable_status(504));
159    }
160
161    #[test]
162    fn non_retryable_status_codes() {
163        assert!(!is_retryable_status(200));
164        assert!(!is_retryable_status(400));
165        assert!(!is_retryable_status(401));
166        assert!(!is_retryable_status(403));
167        assert!(!is_retryable_status(404));
168    }
169}