Skip to main content

aether_core/core/
retry_config.rs

1use std::time::Duration;
2
3/// Backoff/retry policy for transient LLM provider failures.
4///
5/// On a retryable `LlmError` (5xx, 429, timeout, network drop, mid-stream
6/// interruption), the agent waits `delay` and re-issues the same request.
7/// Each successful turn resets the attempt counter.
8///
9/// Backoff doubles each attempt: `base_delay`, `2 * base_delay`, `4 * base_delay`,
10/// ... capped at `max_delay`.
11#[derive(Debug, Clone, Copy)]
12pub struct RetryConfig {
13    pub max_attempts: u32,
14    pub base_delay: Duration,
15    pub max_delay: Duration,
16}
17
18impl Default for RetryConfig {
19    fn default() -> Self {
20        Self { max_attempts: 5, base_delay: Duration::from_millis(200), max_delay: Duration::from_secs(30) }
21    }
22}
23
24impl RetryConfig {
25    pub fn disabled() -> Self {
26        Self { max_attempts: 0, ..Self::default() }
27    }
28
29    /// Compute the delay before the next retry. Exponential backoff (×2 per
30    /// attempt) starting from `base_delay`, capped at `max_delay`.
31    pub(crate) fn compute_delay(&self, attempt: u32) -> Duration {
32        let multiplier = 2u32.saturating_pow(attempt.saturating_sub(1));
33        self.base_delay.saturating_mul(multiplier).min(self.max_delay)
34    }
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40
41    #[test]
42    fn exponential_backoff_doubles_per_attempt() {
43        let config =
44            RetryConfig { max_attempts: 5, base_delay: Duration::from_millis(100), max_delay: Duration::from_secs(30) };
45        assert_eq!(config.compute_delay(1), Duration::from_millis(100));
46        assert_eq!(config.compute_delay(2), Duration::from_millis(200));
47        assert_eq!(config.compute_delay(3), Duration::from_millis(400));
48        assert_eq!(config.compute_delay(4), Duration::from_millis(800));
49    }
50
51    #[test]
52    fn backoff_is_capped_by_max_delay() {
53        let config = RetryConfig {
54            max_attempts: 10,
55            base_delay: Duration::from_millis(100),
56            max_delay: Duration::from_millis(500),
57        };
58        assert_eq!(config.compute_delay(10), Duration::from_millis(500));
59    }
60
61    #[test]
62    fn extreme_attempt_count_does_not_panic() {
63        let config = RetryConfig {
64            max_attempts: 100,
65            base_delay: Duration::from_millis(100),
66            max_delay: Duration::from_secs(30),
67        };
68        assert_eq!(config.compute_delay(99), Duration::from_secs(30));
69    }
70
71    #[test]
72    fn disabled_config_has_zero_max_attempts() {
73        assert_eq!(RetryConfig::disabled().max_attempts, 0);
74    }
75}