Skip to main content

rai_sdk/
retry.rs

1//! Retry policy for transient provider failures.
2//!
3//! [`RetryConfig`] describes exponential backoff with optional jitter. It is
4//! applied automatically around provider calls, and only to errors that
5//! [`Error::is_retryable`](crate::Error::is_retryable) reports as transient
6//! (rate limits, timeouts, and transport-level HTTP errors). Non-transient
7//! errors are returned immediately, so a bad request never sleeps.
8//!
9//! The delay for attempt `n` is `initial_delay * backoff_multiplier^n`, clamped
10//! to `max_delay`; with jitter enabled, a random offset of up to 50% of that
11//! value is added.
12
13use std::time::Duration;
14
15use rand::RngExt;
16use serde::{Deserialize, Serialize};
17use tracing::warn;
18
19/// Configuration for automatic retry with exponential backoff.
20///
21/// Applied to retryable errors (`RateLimit`, `Timeout`, `Http`).
22///
23/// Attach it to a client with
24/// [`ClientBuilder::retry_config`](crate::ClientBuilder::retry_config) or to a
25/// single request with
26/// [`RequestBuilder::retry_config`](crate::RequestBuilder::retry_config).
27///
28/// # Examples
29///
30/// ```rust
31/// use std::time::Duration;
32/// use rai_sdk::RetryConfig;
33///
34/// // Default: 3 retries, 1s initial delay, 2x backoff, jitter enabled
35/// let config = RetryConfig::default();
36///
37/// // Custom: 5 retries, 500ms initial delay, no jitter
38/// let custom = RetryConfig::new()
39///     .with_max_retries(5)
40///     .with_initial_delay(Duration::from_millis(500))
41///     .with_jitter(false);
42///
43/// // Disable retries entirely
44/// let none = RetryConfig::none();
45/// ```
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct RetryConfig {
48    /// Maximum number of retry attempts (0 = no retries).
49    pub max_retries: u32,
50    /// Delay before the first retry.
51    pub initial_delay: Duration,
52    /// Maximum delay between retries (caps exponential growth).
53    pub max_delay: Duration,
54    /// Multiplier applied to the delay after each attempt.
55    pub backoff_multiplier: f64,
56    /// Add random jitter to avoid thundering herd.
57    pub jitter: bool,
58}
59
60impl Default for RetryConfig {
61    fn default() -> Self {
62        Self {
63            max_retries: 3,
64            initial_delay: Duration::from_secs(1),
65            max_delay: Duration::from_secs(60),
66            backoff_multiplier: 2.0,
67            jitter: true,
68        }
69    }
70}
71
72impl RetryConfig {
73    /// Create a default retry configuration (3 retries, 1s initial delay, 2x backoff).
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Create a configuration that disables retries entirely.
79    pub fn none() -> Self {
80        Self {
81            max_retries: 0,
82            ..Self::default()
83        }
84    }
85
86    /// Set the maximum number of retry attempts.
87    pub fn with_max_retries(mut self, n: u32) -> Self {
88        self.max_retries = n;
89        self
90    }
91
92    /// Set the initial delay before the first retry.
93    pub fn with_initial_delay(mut self, delay: Duration) -> Self {
94        self.initial_delay = delay;
95        self
96    }
97
98    /// Set the maximum delay between retries.
99    pub fn with_max_delay(mut self, delay: Duration) -> Self {
100        self.max_delay = delay;
101        self
102    }
103
104    /// Set the backoff multiplier.
105    pub fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
106        self.backoff_multiplier = multiplier;
107        self
108    }
109
110    /// Enable or disable jitter.
111    pub fn with_jitter(mut self, jitter: bool) -> Self {
112        self.jitter = jitter;
113        self
114    }
115
116    /// Compute the delay for a given attempt (0-indexed).
117    pub(crate) fn delay_for_attempt(&self, attempt: u32) -> Duration {
118        let base = self.initial_delay.as_secs_f64() * self.backoff_multiplier.powi(attempt as i32);
119        let capped = base.min(self.max_delay.as_secs_f64());
120
121        let final_delay = if self.jitter {
122            let jitter_range = capped * 0.5;
123            let jitter_offset = rand::rng().random_range(0.0..jitter_range);
124            capped + jitter_offset
125        } else {
126            capped
127        };
128
129        Duration::from_secs_f64(final_delay)
130    }
131}
132
133/// Execute an async operation with retry logic.
134///
135/// The `operation` closure is called repeatedly until it succeeds or the retry
136/// limit is exceeded. Only errors where `Error::is_retryable()` returns `true`
137/// are retried.
138pub(crate) async fn with_retry<F, Fut, T>(
139    config: &RetryConfig,
140    operation_name: &str,
141    mut operation: F,
142) -> crate::error::Result<T>
143where
144    F: FnMut() -> Fut,
145    Fut: std::future::Future<Output = crate::error::Result<T>>,
146{
147    for attempt in 0..=config.max_retries {
148        match operation().await {
149            Ok(result) => return Ok(result),
150            Err(e) => {
151                if !e.is_retryable() || attempt == config.max_retries {
152                    return Err(e);
153                }
154
155                let delay = config.delay_for_attempt(attempt);
156                warn!(
157                    operation = operation_name,
158                    attempt = attempt + 1,
159                    max_retries = config.max_retries,
160                    delay_ms = delay.as_millis() as u64,
161                    error_kind = e.kind_str(),
162                    provider = ?e.provider(),
163                    error = %e,
164                    "Retrying after transient error"
165                );
166
167                tokio::time::sleep(delay).await;
168            }
169        }
170    }
171
172    // Unreachable: the loop always returns on the final attempt.
173    unreachable!("retry loop should have returned")
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn default_config() {
182        let config = RetryConfig::default();
183        assert_eq!(config.max_retries, 3);
184        assert_eq!(config.initial_delay, Duration::from_secs(1));
185        assert_eq!(config.max_delay, Duration::from_secs(60));
186        assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
187        assert!(config.jitter);
188    }
189
190    #[test]
191    fn none_config() {
192        let config = RetryConfig::none();
193        assert_eq!(config.max_retries, 0);
194    }
195
196    #[test]
197    fn delay_exponential_growth() {
198        let config = RetryConfig::new().with_jitter(false);
199
200        let d0 = config.delay_for_attempt(0);
201        let d1 = config.delay_for_attempt(1);
202        let d2 = config.delay_for_attempt(2);
203
204        assert_eq!(d0, Duration::from_secs(1)); // 1 * 2^0 = 1s
205        assert_eq!(d1, Duration::from_secs(2)); // 1 * 2^1 = 2s
206        assert_eq!(d2, Duration::from_secs(4)); // 1 * 2^2 = 4s
207    }
208
209    #[test]
210    fn delay_capped_at_max() {
211        let config = RetryConfig::new()
212            .with_jitter(false)
213            .with_max_delay(Duration::from_secs(3));
214
215        let d0 = config.delay_for_attempt(0); // 1s
216        let d1 = config.delay_for_attempt(1); // 2s
217        let d2 = config.delay_for_attempt(2); // 4s -> capped to 3s
218        let d3 = config.delay_for_attempt(3); // 8s -> capped to 3s
219
220        assert_eq!(d0, Duration::from_secs(1));
221        assert_eq!(d1, Duration::from_secs(2));
222        assert_eq!(d2, Duration::from_secs(3));
223        assert_eq!(d3, Duration::from_secs(3));
224    }
225
226    #[test]
227    fn delay_with_jitter_is_bounded() {
228        let config = RetryConfig::new().with_jitter(true);
229
230        // With jitter, delay should be in [base, base * 1.5)
231        for attempt in 0..5 {
232            let base =
233                config.initial_delay.as_secs_f64() * config.backoff_multiplier.powi(attempt as i32);
234            let capped = base.min(config.max_delay.as_secs_f64());
235
236            let delay = config.delay_for_attempt(attempt);
237            let delay_secs = delay.as_secs_f64();
238
239            assert!(
240                delay_secs >= capped,
241                "attempt {attempt}: delay {delay_secs} < base {capped}"
242            );
243            assert!(
244                delay_secs < capped * 1.5,
245                "attempt {attempt}: delay {delay_secs} >= max {:.2}",
246                capped * 1.5
247            );
248        }
249    }
250
251    #[test]
252    fn builder_chain() {
253        let config = RetryConfig::new()
254            .with_max_retries(5)
255            .with_initial_delay(Duration::from_millis(500))
256            .with_max_delay(Duration::from_secs(30))
257            .with_backoff_multiplier(3.0)
258            .with_jitter(false);
259
260        assert_eq!(config.max_retries, 5);
261        assert_eq!(config.initial_delay, Duration::from_millis(500));
262        assert_eq!(config.max_delay, Duration::from_secs(30));
263        assert!((config.backoff_multiplier - 3.0).abs() < f64::EPSILON);
264        assert!(!config.jitter);
265    }
266}