Skip to main content

synth_ai_core/
polling.rs

1//! Polling and retry utilities.
2//!
3//! This module provides exponential backoff configuration and calculation
4//! for polling loops. It's designed to be used across all SDK languages.
5
6use std::time::Duration;
7
8/// Configuration for exponential backoff.
9#[derive(Debug, Clone)]
10pub struct BackoffConfig {
11    /// Base interval in milliseconds (default: 5000ms = 5s)
12    pub base_interval_ms: u64,
13    /// Maximum backoff in milliseconds (default: 60000ms = 60s)
14    pub max_backoff_ms: u64,
15    /// Maximum exponent for backoff calculation (default: 4, giving max multiplier of 16)
16    pub max_exponent: u32,
17}
18
19impl Default for BackoffConfig {
20    fn default() -> Self {
21        Self {
22            base_interval_ms: 5000, // 5 seconds
23            max_backoff_ms: 60000,  // 60 seconds
24            max_exponent: 4,        // 2^4 = 16x max multiplier
25        }
26    }
27}
28
29impl BackoffConfig {
30    /// Create a new backoff config with custom values.
31    pub fn new(base_interval_ms: u64, max_backoff_ms: u64, max_exponent: u32) -> Self {
32        Self {
33            base_interval_ms,
34            max_backoff_ms,
35            max_exponent,
36        }
37    }
38
39    /// Create a fast backoff config for quick retries.
40    pub fn fast() -> Self {
41        Self {
42            base_interval_ms: 1000, // 1 second
43            max_backoff_ms: 10000,  // 10 seconds
44            max_exponent: 3,        // 2^3 = 8x max multiplier
45        }
46    }
47
48    /// Create an aggressive backoff config for rate-limited scenarios.
49    pub fn aggressive() -> Self {
50        Self {
51            base_interval_ms: 10000, // 10 seconds
52            max_backoff_ms: 300000,  // 5 minutes
53            max_exponent: 5,         // 2^5 = 32x max multiplier
54        }
55    }
56}
57
58/// Calculate backoff delay for a given number of consecutive failures.
59///
60/// Formula: `min(base * 2^min(consecutive-1, max_exponent), max_backoff)`
61///
62/// # Arguments
63///
64/// * `config` - Backoff configuration
65/// * `consecutive_failures` - Number of consecutive failures (0 = first attempt)
66///
67/// # Returns
68///
69/// Duration to wait before next attempt
70///
71/// # Example
72///
73/// ```
74/// use synth_ai_core::polling::{BackoffConfig, calculate_backoff};
75///
76/// let config = BackoffConfig::default();
77///
78/// // First failure: 5s (base)
79/// let delay1 = calculate_backoff(&config, 1);
80/// assert_eq!(delay1.as_millis(), 5000);
81///
82/// // Second failure: 10s (base * 2)
83/// let delay2 = calculate_backoff(&config, 2);
84/// assert_eq!(delay2.as_millis(), 10000);
85///
86/// // Third failure: 20s (base * 4)
87/// let delay3 = calculate_backoff(&config, 3);
88/// assert_eq!(delay3.as_millis(), 20000);
89/// ```
90pub fn calculate_backoff(config: &BackoffConfig, consecutive_failures: u32) -> Duration {
91    if consecutive_failures == 0 {
92        return Duration::from_millis(config.base_interval_ms);
93    }
94
95    // Exponent is (consecutive - 1), capped at max_exponent
96    let exponent = (consecutive_failures.saturating_sub(1)).min(config.max_exponent);
97    let multiplier = 2u64.saturating_pow(exponent);
98    let delay_ms = config
99        .base_interval_ms
100        .saturating_mul(multiplier)
101        .min(config.max_backoff_ms);
102
103    Duration::from_millis(delay_ms)
104}
105
106/// Calculate backoff delay in milliseconds (convenience function).
107pub fn calculate_backoff_ms(
108    base_interval_ms: u64,
109    max_backoff_ms: u64,
110    consecutive_failures: u32,
111) -> u64 {
112    let config = BackoffConfig {
113        base_interval_ms,
114        max_backoff_ms,
115        max_exponent: 4, // default
116    };
117    calculate_backoff(&config, consecutive_failures).as_millis() as u64
118}
119
120/// Result of a single poll operation.
121#[derive(Debug, Clone)]
122pub enum PollResult<T> {
123    /// Continue polling (no terminal state reached)
124    Continue,
125    /// Polling complete with terminal result
126    Terminal(T),
127    /// Polling encountered an error (may retry)
128    Error(String),
129}
130
131/// Configuration for a polling loop.
132#[derive(Debug, Clone)]
133pub struct PollConfig {
134    /// Backoff configuration for failures
135    pub backoff: BackoffConfig,
136    /// Maximum number of consecutive errors before giving up
137    pub max_consecutive_errors: u32,
138    /// Overall timeout in seconds (0 = no timeout)
139    pub timeout_secs: u64,
140    /// Base polling interval when no errors (milliseconds)
141    pub poll_interval_ms: u64,
142}
143
144impl Default for PollConfig {
145    fn default() -> Self {
146        Self {
147            backoff: BackoffConfig::default(),
148            max_consecutive_errors: 5,
149            timeout_secs: 0,        // no timeout
150            poll_interval_ms: 5000, // 5 seconds
151        }
152    }
153}
154
155/// State tracker for a polling loop.
156#[derive(Debug)]
157pub struct PollState {
158    /// Number of consecutive errors
159    pub consecutive_errors: u32,
160    /// Total number of poll attempts
161    pub total_attempts: u32,
162    /// Start time (for timeout tracking)
163    start_time: std::time::Instant,
164    /// Configuration
165    config: PollConfig,
166}
167
168impl PollState {
169    /// Create a new polling state.
170    pub fn new(config: PollConfig) -> Self {
171        Self {
172            consecutive_errors: 0,
173            total_attempts: 0,
174            start_time: std::time::Instant::now(),
175            config,
176        }
177    }
178
179    /// Record a successful poll (resets consecutive errors).
180    pub fn record_success(&mut self) {
181        self.consecutive_errors = 0;
182        self.total_attempts += 1;
183    }
184
185    /// Record a failed poll.
186    pub fn record_error(&mut self) {
187        self.consecutive_errors += 1;
188        self.total_attempts += 1;
189    }
190
191    /// Check if we should give up due to too many errors.
192    pub fn should_give_up(&self) -> bool {
193        self.consecutive_errors >= self.config.max_consecutive_errors
194    }
195
196    /// Check if we've timed out.
197    pub fn is_timed_out(&self) -> bool {
198        if self.config.timeout_secs == 0 {
199            return false;
200        }
201        self.start_time.elapsed().as_secs() >= self.config.timeout_secs
202    }
203
204    /// Get the next delay to wait.
205    pub fn next_delay(&self) -> Duration {
206        if self.consecutive_errors > 0 {
207            calculate_backoff(&self.config.backoff, self.consecutive_errors)
208        } else {
209            Duration::from_millis(self.config.poll_interval_ms)
210        }
211    }
212
213    /// Get elapsed time since polling started.
214    pub fn elapsed(&self) -> Duration {
215        self.start_time.elapsed()
216    }
217}
218
219/// Retry helper for async operations.
220///
221/// # Example
222///
223/// ```ignore
224/// use synth_ai_core::polling::RetryConfig;
225///
226/// let config = RetryConfig::default();
227/// let result = config.retry(|| async {
228///     // Your fallible operation here
229///     fetch_data().await
230/// }).await?;
231/// ```
232#[derive(Debug, Clone)]
233pub struct RetryConfig {
234    /// Maximum number of attempts (including first try)
235    pub max_attempts: u32,
236    /// Initial delay between retries in milliseconds
237    pub initial_delay_ms: u64,
238    /// Backoff multiplier (e.g., 2.0 for doubling)
239    pub backoff_multiplier: f64,
240    /// Maximum delay in milliseconds
241    pub max_delay_ms: u64,
242}
243
244impl Default for RetryConfig {
245    fn default() -> Self {
246        Self {
247            max_attempts: 3,
248            initial_delay_ms: 1000,
249            backoff_multiplier: 2.0,
250            max_delay_ms: 30000,
251        }
252    }
253}
254
255impl RetryConfig {
256    /// Calculate delay for a given attempt number (0-indexed).
257    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
258        if attempt == 0 {
259            return Duration::from_millis(self.initial_delay_ms);
260        }
261
262        let multiplier = self.backoff_multiplier.powi(attempt as i32);
263        let delay_ms = (self.initial_delay_ms as f64 * multiplier) as u64;
264        Duration::from_millis(delay_ms.min(self.max_delay_ms))
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_calculate_backoff_default() {
274        let config = BackoffConfig::default();
275
276        // First failure: base (5s)
277        assert_eq!(calculate_backoff(&config, 1).as_millis(), 5000);
278
279        // Second failure: base * 2 (10s)
280        assert_eq!(calculate_backoff(&config, 2).as_millis(), 10000);
281
282        // Third failure: base * 4 (20s)
283        assert_eq!(calculate_backoff(&config, 3).as_millis(), 20000);
284
285        // Fourth failure: base * 8 (40s)
286        assert_eq!(calculate_backoff(&config, 4).as_millis(), 40000);
287
288        // Fifth failure: base * 16 = 80s, but capped at 60s
289        assert_eq!(calculate_backoff(&config, 5).as_millis(), 60000);
290
291        // Sixth+ failure: still capped at 60s
292        assert_eq!(calculate_backoff(&config, 6).as_millis(), 60000);
293        assert_eq!(calculate_backoff(&config, 10).as_millis(), 60000);
294    }
295
296    #[test]
297    fn test_calculate_backoff_zero_failures() {
298        let config = BackoffConfig::default();
299        assert_eq!(calculate_backoff(&config, 0).as_millis(), 5000);
300    }
301
302    #[test]
303    fn test_calculate_backoff_fast() {
304        let config = BackoffConfig::fast();
305        // Fast config: base=1000, max=10000, max_exponent=3
306        assert_eq!(calculate_backoff(&config, 1).as_millis(), 1000); // 1000 * 2^0 = 1000
307        assert_eq!(calculate_backoff(&config, 2).as_millis(), 2000); // 1000 * 2^1 = 2000
308        assert_eq!(calculate_backoff(&config, 3).as_millis(), 4000); // 1000 * 2^2 = 4000
309        assert_eq!(calculate_backoff(&config, 4).as_millis(), 8000); // 1000 * 2^3 = 8000
310                                                                     // max_exponent=3, so exponent is capped at 3 (8x multiplier)
311        assert_eq!(calculate_backoff(&config, 5).as_millis(), 8000); // 1000 * 2^3 = 8000 (capped)
312        assert_eq!(calculate_backoff(&config, 10).as_millis(), 8000); // 1000 * 2^3 = 8000 (capped)
313    }
314
315    #[test]
316    fn test_poll_state() {
317        let config = PollConfig {
318            max_consecutive_errors: 3,
319            timeout_secs: 0,
320            ..Default::default()
321        };
322
323        let mut state = PollState::new(config);
324
325        // Initially no errors
326        assert!(!state.should_give_up());
327        assert_eq!(state.consecutive_errors, 0);
328
329        // Record some errors
330        state.record_error();
331        assert!(!state.should_give_up());
332        state.record_error();
333        assert!(!state.should_give_up());
334        state.record_error();
335        assert!(state.should_give_up());
336
337        // Success resets
338        state.record_success();
339        assert!(!state.should_give_up());
340        assert_eq!(state.consecutive_errors, 0);
341    }
342
343    #[test]
344    fn test_retry_delay() {
345        let config = RetryConfig {
346            max_attempts: 5,
347            initial_delay_ms: 1000,
348            backoff_multiplier: 2.0,
349            max_delay_ms: 10000,
350        };
351
352        assert_eq!(config.delay_for_attempt(0).as_millis(), 1000);
353        assert_eq!(config.delay_for_attempt(1).as_millis(), 2000);
354        assert_eq!(config.delay_for_attempt(2).as_millis(), 4000);
355        assert_eq!(config.delay_for_attempt(3).as_millis(), 8000);
356        // Capped at 10s
357        assert_eq!(config.delay_for_attempt(4).as_millis(), 10000);
358    }
359}