Skip to main content

openrouter/
retry.rs

1//! Retry + exponential-backoff-with-jitter middleware.
2//!
3//! Constants mirror the Go SDK's defaults so behavior matches across the two.
4
5use std::future::Future;
6use std::time::Duration;
7
8use rand::Rng;
9
10use crate::error::{Error, Result};
11
12/// Jitter as a fraction of the computed delay (±25 %).
13pub const DEFAULT_JITTER_FACTOR: f64 = 0.25;
14/// Maximum single-step delay between retry attempts.
15pub const DEFAULT_MAX_DELAY: Duration = Duration::from_secs(30);
16/// Exponential multiplier between attempts.
17pub const DEFAULT_MULTIPLIER: f64 = 2.0;
18/// Cap on stream-reconnect backoff (used by Phase 2 streaming).
19pub const MAX_RECONNECT_BACKOFF: Duration = Duration::from_secs(10);
20/// Default retry budget.
21pub const DEFAULT_MAX_RETRIES: u32 = 3;
22/// Default first-attempt delay.
23pub const DEFAULT_INITIAL_DELAY: Duration = Duration::from_secs(1);
24
25/// Retry / backoff configuration.
26#[derive(Clone, Debug)]
27pub struct RetryConfig {
28    /// Maximum number of retries after the initial attempt.
29    pub max_retries: u32,
30    /// Delay before the first retry. Subsequent delays grow by
31    /// [`Self::multiplier`] up to [`Self::max_delay`].
32    pub initial_delay: Duration,
33    /// Upper bound for any single sleep between attempts.
34    pub max_delay: Duration,
35    /// Exponential growth multiplier between attempts (Go SDK uses 2.0).
36    pub multiplier: f64,
37    /// Jitter as a fraction of the computed delay (±this proportion).
38    pub jitter_factor: f64,
39}
40
41impl Default for RetryConfig {
42    fn default() -> Self {
43        Self {
44            max_retries: DEFAULT_MAX_RETRIES,
45            initial_delay: DEFAULT_INITIAL_DELAY,
46            max_delay: DEFAULT_MAX_DELAY,
47            multiplier: DEFAULT_MULTIPLIER,
48            jitter_factor: DEFAULT_JITTER_FACTOR,
49        }
50    }
51}
52
53impl RetryConfig {
54    /// Compute the (jittered) delay before the *next* retry, given the
55    /// 1-indexed attempt number that just failed.
56    pub(crate) fn delay_for_attempt(&self, attempt: u32) -> Duration {
57        let base = self.initial_delay.as_secs_f64() * self.multiplier.powi(attempt as i32 - 1);
58        let capped = base.min(self.max_delay.as_secs_f64());
59        let jitter_span = capped * self.jitter_factor;
60        let lo = (capped - jitter_span).max(0.0);
61        let hi = capped + jitter_span;
62        let secs = if lo >= hi {
63            capped
64        } else {
65            rand::thread_rng().gen_range(lo..hi)
66        };
67        Duration::from_secs_f64(secs)
68    }
69}
70
71/// Run `op` with retries on transient errors.
72///
73/// `op` is invoked at least once. On a transient failure we sleep
74/// `delay_for_attempt(n)` (or the error's `Retry-After`, when larger)
75/// before the next try. Non-transient failures return immediately.
76pub(crate) async fn run_with_retry<F, Fut, T>(cfg: &RetryConfig, mut op: F) -> Result<T>
77where
78    F: FnMut() -> Fut,
79    Fut: Future<Output = Result<T>>,
80{
81    let mut attempt: u32 = 0;
82    loop {
83        attempt += 1;
84        match op().await {
85            Ok(v) => return Ok(v),
86            Err(e) => {
87                if !e.is_transient() || attempt > cfg.max_retries {
88                    if attempt > 1 {
89                        return Err(Error::RetryExhausted {
90                            attempts: attempt,
91                            source: Box::new(e),
92                        });
93                    }
94                    return Err(e);
95                }
96                let computed = cfg.delay_for_attempt(attempt);
97                let delay = e
98                    .retry_after()
99                    .map(|ra| ra.max(computed))
100                    .unwrap_or(computed);
101                crate::timer::sleep(delay).await;
102            }
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::sync::atomic::{AtomicU32, Ordering};
111    use std::sync::Arc;
112
113    fn api(status: u16) -> Error {
114        Error::Api {
115            status,
116            code: None,
117            message: "x".into(),
118            metadata: None,
119            provider: None,
120            retry_after: None,
121        }
122    }
123
124    #[tokio::test(start_paused = true)]
125    async fn succeeds_first_try() {
126        let calls = Arc::new(AtomicU32::new(0));
127        let c = calls.clone();
128        let cfg = RetryConfig::default();
129        let r: Result<u32> = run_with_retry(&cfg, || {
130            let c = c.clone();
131            async move {
132                c.fetch_add(1, Ordering::SeqCst);
133                Ok(42)
134            }
135        })
136        .await;
137        assert_eq!(r.unwrap(), 42);
138        assert_eq!(calls.load(Ordering::SeqCst), 1);
139    }
140
141    #[tokio::test(start_paused = true)]
142    async fn retries_transient_then_succeeds() {
143        let calls = Arc::new(AtomicU32::new(0));
144        let c = calls.clone();
145        let cfg = RetryConfig {
146            max_retries: 3,
147            initial_delay: Duration::from_millis(10),
148            max_delay: Duration::from_millis(50),
149            multiplier: 2.0,
150            jitter_factor: 0.0,
151        };
152        let r: Result<u32> = run_with_retry(&cfg, || {
153            let c = c.clone();
154            async move {
155                let n = c.fetch_add(1, Ordering::SeqCst) + 1;
156                if n < 3 {
157                    Err(api(503))
158                } else {
159                    Ok(7)
160                }
161            }
162        })
163        .await;
164        assert_eq!(r.unwrap(), 7);
165        assert_eq!(calls.load(Ordering::SeqCst), 3);
166    }
167
168    #[tokio::test(start_paused = true)]
169    async fn non_transient_errors_short_circuit() {
170        let calls = Arc::new(AtomicU32::new(0));
171        let c = calls.clone();
172        let cfg = RetryConfig::default();
173        let r: Result<()> = run_with_retry(&cfg, || {
174            let c = c.clone();
175            async move {
176                c.fetch_add(1, Ordering::SeqCst);
177                Err(api(400))
178            }
179        })
180        .await;
181        assert!(matches!(r, Err(Error::Api { status: 400, .. })));
182        assert_eq!(calls.load(Ordering::SeqCst), 1);
183    }
184
185    #[tokio::test(start_paused = true)]
186    async fn exhausts_budget() {
187        let calls = Arc::new(AtomicU32::new(0));
188        let c = calls.clone();
189        let cfg = RetryConfig {
190            max_retries: 2,
191            initial_delay: Duration::from_millis(1),
192            max_delay: Duration::from_millis(5),
193            multiplier: 2.0,
194            jitter_factor: 0.0,
195        };
196        let r: Result<()> = run_with_retry(&cfg, || {
197            let c = c.clone();
198            async move {
199                c.fetch_add(1, Ordering::SeqCst);
200                Err(api(503))
201            }
202        })
203        .await;
204        match r {
205            Err(Error::RetryExhausted { attempts, source }) => {
206                assert_eq!(attempts, 3);
207                assert!(matches!(*source, Error::Api { status: 503, .. }));
208            }
209            other => panic!("expected RetryExhausted, got {other:?}"),
210        }
211        assert_eq!(calls.load(Ordering::SeqCst), 3);
212    }
213
214    #[test]
215    fn delay_is_within_jitter_window() {
216        let cfg = RetryConfig {
217            max_retries: 5,
218            initial_delay: Duration::from_secs(1),
219            max_delay: Duration::from_secs(30),
220            multiplier: 2.0,
221            jitter_factor: 0.25,
222        };
223        for attempt in 1..=4 {
224            let base = (cfg.initial_delay.as_secs_f64() * 2f64.powi(attempt as i32 - 1))
225                .min(cfg.max_delay.as_secs_f64());
226            let lo = base * 0.75;
227            let hi = base * 1.25;
228            for _ in 0..100 {
229                let d = cfg.delay_for_attempt(attempt).as_secs_f64();
230                assert!(d >= lo - 1e-9 && d <= hi + 1e-9, "{d} not in [{lo},{hi}]");
231            }
232        }
233    }
234
235    #[tokio::test(start_paused = true)]
236    async fn retry_after_overrides_when_larger() {
237        let calls = Arc::new(AtomicU32::new(0));
238        let c = calls.clone();
239        let cfg = RetryConfig {
240            max_retries: 1,
241            initial_delay: Duration::from_millis(1),
242            max_delay: Duration::from_millis(1),
243            multiplier: 2.0,
244            jitter_factor: 0.0,
245        };
246        let start = tokio::time::Instant::now();
247        let r: Result<()> = run_with_retry(&cfg, || {
248            let c = c.clone();
249            async move {
250                let n = c.fetch_add(1, Ordering::SeqCst) + 1;
251                if n == 1 {
252                    Err(Error::Api {
253                        status: 429,
254                        code: None,
255                        message: "rate".into(),
256                        metadata: None,
257                        provider: None,
258                        retry_after: Some(Duration::from_secs(5)),
259                    })
260                } else {
261                    Ok(())
262                }
263            }
264        })
265        .await;
266        assert!(r.is_ok());
267        let elapsed = start.elapsed();
268        assert!(
269            elapsed >= Duration::from_secs(5),
270            "expected >= 5s, got {elapsed:?}"
271        );
272    }
273}