Skip to main content

aimux_core/
retry.rs

1//! Operation-level retry, aligned with the AI SDK retry utilities.
2
3use std::future::Future;
4use std::time::Duration;
5
6use rand::Rng;
7
8use crate::AbortSignal;
9use crate::error::{AiMuxError, RetryError, RetryErrorReason};
10
11const DEFAULT_MAX_RETRIES: u32 = 2;
12const INITIAL_DELAY_MS: u64 = 2_000;
13const BACKOFF_FACTOR: u32 = 2;
14
15/// Default retry settings for model operations.
16///
17/// Per-call `max_retries` overrides only [`Self::max_retries`]; the configured
18/// delay and backoff factor remain in effect.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct RetryConfig {
21    /// Maximum number of retries after the initial attempt.
22    pub max_retries: u32,
23    /// Initial delay between attempts.
24    pub initial_delay: Duration,
25    /// Multiplier applied to the delay after each retry.
26    pub backoff_factor: u32,
27}
28
29impl Default for RetryConfig {
30    fn default() -> Self {
31        Self {
32            max_retries: DEFAULT_MAX_RETRIES,
33            initial_delay: Duration::from_millis(INITIAL_DELAY_MS),
34            backoff_factor: BACKOFF_FACTOR,
35        }
36    }
37}
38
39/// A prepared operation retry function and its resolved retry count.
40///
41/// This is the Rust representation of the AI SDK's
42/// `prepareRetries()` result: `{ maxRetries, retry }`.
43#[derive(Debug, Clone)]
44pub struct PreparedRetries {
45    /// The effective maximum after applying the default.
46    pub max_retries: u32,
47    initial_delay_ms: u64,
48    backoff_factor: u64,
49    abort_signal: Option<AbortSignal>,
50}
51
52/// Bind retry settings, applying a per-call retry-count override.
53#[must_use]
54pub fn prepare_retries(
55    max_retries: Option<u32>,
56    mut config: RetryConfig,
57    abort_signal: Option<AbortSignal>,
58) -> PreparedRetries {
59    if let Some(max_retries) = max_retries {
60        config.max_retries = max_retries;
61    }
62    PreparedRetries {
63        max_retries: config.max_retries,
64        initial_delay_ms: u64::try_from(config.initial_delay.as_millis()).unwrap_or(u64::MAX),
65        backoff_factor: u64::from(config.backoff_factor),
66        abort_signal,
67    }
68}
69
70impl PreparedRetries {
71    /// Retry a complete operation using API-call retryability and response hints.
72    ///
73    /// # Errors
74    ///
75    /// Returns the operation error, retry exhaustion, or caller cancellation.
76    pub async fn retry<F, Fut, T>(&self, operation: F) -> Result<T, AiMuxError>
77    where
78        F: FnMut() -> Fut,
79        Fut: Future<Output = Result<T, AiMuxError>>,
80    {
81        retry_with_exponential_backoff_respecting_retry_headers(
82            operation,
83            self.max_retries,
84            self.initial_delay_ms,
85            self.backoff_factor,
86            self.abort_signal.as_ref(),
87        )
88        .await
89    }
90}
91
92async fn retry_with_exponential_backoff_respecting_retry_headers<F, Fut, T>(
93    operation: F,
94    max_retries: u32,
95    initial_delay_ms: u64,
96    backoff_factor: u64,
97    abort_signal: Option<&AbortSignal>,
98) -> Result<T, AiMuxError>
99where
100    F: FnMut() -> Fut,
101    Fut: Future<Output = Result<T, AiMuxError>>,
102{
103    retry_with_exponential_backoff(
104        operation,
105        max_retries,
106        initial_delay_ms,
107        backoff_factor,
108        abort_signal,
109        |error: &AiMuxError| matches!(error, AiMuxError::ApiCall(detail) if detail.is_retryable),
110        |error: &AiMuxError, exponential_delay| {
111            retry_delay_with_jitter(error, exponential_delay, |maximum| {
112                rand::thread_rng().gen_range(0..=maximum)
113            })
114        },
115    )
116    .await
117}
118
119/// Generic exponential retry primitive. The caller supplies retry
120/// classification and delay selection.
121///
122/// # Errors
123///
124/// Returns the operation error, retry exhaustion, or caller cancellation.
125pub(crate) async fn retry_with_exponential_backoff<F, Fut, T, ShouldRetry, GetDelay>(
126    mut operation: F,
127    max_retries: u32,
128    initial_delay_ms: u64,
129    backoff_factor: u64,
130    abort_signal: Option<&AbortSignal>,
131    mut should_retry: ShouldRetry,
132    mut get_delay_ms: GetDelay,
133) -> Result<T, AiMuxError>
134where
135    F: FnMut() -> Fut,
136    Fut: Future<Output = Result<T, AiMuxError>>,
137    ShouldRetry: FnMut(&AiMuxError) -> bool,
138    GetDelay: FnMut(&AiMuxError, u64) -> u64,
139{
140    let mut errors = Vec::new();
141    let mut exponential_delay = initial_delay_ms;
142
143    loop {
144        let result = match abort_signal {
145            Some(signal) => {
146                if signal.is_aborted() {
147                    return Err(AiMuxError::from_abort_signal(signal));
148                }
149                tokio::select! {
150                    biased;
151                    () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)),
152                    result = async { operation().await } => result,
153                }
154            }
155            None => operation().await,
156        };
157
158        match result {
159            Ok(value) => return Ok(value),
160            Err(error @ (AiMuxError::Aborted(_) | AiMuxError::Timeout(_))) => {
161                return Err(error);
162            }
163            // A safe inner exchange may exhaust its own retries inside a
164            // larger non-idempotent operation. Pass that exhaustion through
165            // so the enclosing operation is not submitted again.
166            Err(error @ AiMuxError::Retry(_)) => return Err(error),
167            Err(error) if max_retries == 0 => return Err(error),
168            Err(error) => {
169                tracing::warn!(
170                    target: "aimux_core::retry",
171                    attempt = errors.len() + 1,
172                    error = %error,
173                    "operation attempt failed"
174                );
175                errors.push(error);
176                let try_number = errors.len() as u32;
177
178                if try_number > max_retries {
179                    tracing::error!(
180                        target: "aimux_core::retry",
181                        attempts = errors.len(),
182                        error = %errors.last().expect("retry history is non-empty"),
183                        "operation retry exhausted"
184                    );
185                    return Err(AiMuxError::Retry(RetryError {
186                        reason: RetryErrorReason::MaxRetriesExceeded,
187                        errors,
188                    }));
189                }
190
191                let error = errors.last().expect("attempt error was just appended");
192                if should_retry(error) {
193                    let retry_delay = get_delay_ms(error, exponential_delay);
194                    delay(Duration::from_millis(retry_delay), abort_signal).await?;
195                    exponential_delay = exponential_delay.saturating_mul(backoff_factor);
196                    continue;
197                }
198
199                if try_number == 1 {
200                    tracing::error!(
201                        target: "aimux_core::retry",
202                        error = %error,
203                        "operation failed without retry"
204                    );
205                    return Err(errors.pop().expect("the first attempt error exists"));
206                }
207
208                tracing::error!(
209                    target: "aimux_core::retry",
210                    attempts = errors.len(),
211                    error = %error,
212                    "operation stopped on non-retryable error"
213                );
214                return Err(AiMuxError::Retry(RetryError {
215                    reason: RetryErrorReason::ErrorNotRetryable,
216                    errors,
217                }));
218            }
219        }
220    }
221}
222
223/// Abort-aware delay between attempts (also used by video poll pacing).
224///
225/// # Errors
226///
227/// Returns [`AiMuxError::Aborted`] when the caller cancels during the delay.
228pub(crate) async fn delay(
229    duration: Duration,
230    abort_signal: Option<&AbortSignal>,
231) -> Result<(), AiMuxError> {
232    match abort_signal {
233        Some(signal) => {
234            tokio::select! {
235                biased;
236                () = signal.cancelled() => Err(AiMuxError::from_abort_signal(signal)),
237                () = tokio::time::sleep(duration) => Ok(()),
238            }
239        }
240        None => {
241            tokio::time::sleep(duration).await;
242            Ok(())
243        }
244    }
245}
246
247fn retry_header_delay(error: &AiMuxError, exponential_delay: u64) -> Option<u64> {
248    let parsed = u64::try_from(error.retry_after_hint()?).ok()?;
249
250    (parsed < 60_000 || parsed < exponential_delay).then_some(parsed)
251}
252
253fn retry_delay_with_jitter(
254    error: &AiMuxError,
255    exponential_delay: u64,
256    jitter: impl FnOnce(u64) -> u64,
257) -> u64 {
258    retry_header_delay(error, exponential_delay)
259        .unwrap_or_else(|| jitter(exponential_delay).min(exponential_delay))
260}
261
262#[cfg(test)]
263mod tests {
264    use std::collections::HashMap;
265    use std::sync::Arc;
266    use std::sync::atomic::{AtomicUsize, Ordering};
267
268    use super::*;
269    use crate::ApiCallError;
270
271    fn api_error(retryable: bool) -> AiMuxError {
272        AiMuxError::ApiCall(Box::new(ApiCallError {
273            is_retryable: retryable,
274            ..ApiCallError::new("retry", "https://example.test", serde_json::json!({}))
275        }))
276    }
277
278    fn api_error_with_headers(status: u16, headers: &[(&str, &str)]) -> AiMuxError {
279        AiMuxError::ApiCall(Box::new(ApiCallError {
280            status_code: Some(status),
281            response_headers: Some(
282                headers
283                    .iter()
284                    .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
285                    .collect::<HashMap<_, _>>(),
286            ),
287            is_retryable: true,
288            ..ApiCallError::new("retry", "https://example.test", serde_json::json!({}))
289        }))
290    }
291
292    fn should_retry(error: &AiMuxError) -> bool {
293        error.is_retryable()
294    }
295
296    fn unchanged_delay(_error: &AiMuxError, delay: u64) -> u64 {
297        delay
298    }
299
300    #[tokio::test]
301    async fn prepared_retries_resolves_the_default_and_binds_abort() {
302        assert_eq!(
303            prepare_retries(None, RetryConfig::default(), None).max_retries,
304            2
305        );
306
307        let signal = AbortSignal::new();
308        signal.abort();
309        let retries = prepare_retries(Some(4), RetryConfig::default(), Some(signal));
310        let attempts = AtomicUsize::new(0);
311
312        let error = retries
313            .retry(|| {
314                attempts.fetch_add(1, Ordering::SeqCst);
315                async { Ok::<(), AiMuxError>(()) }
316            })
317            .await
318            .unwrap_err();
319
320        assert_eq!(retries.max_retries, 4);
321        assert!(matches!(error, AiMuxError::Aborted(_)));
322        assert_eq!(attempts.load(Ordering::SeqCst), 0);
323    }
324
325    #[test]
326    fn per_call_max_preserves_the_model_delay_and_backoff() {
327        let retries = prepare_retries(
328            Some(4),
329            RetryConfig {
330                max_retries: 7,
331                initial_delay: Duration::from_millis(123),
332                backoff_factor: 3,
333            },
334            None,
335        );
336
337        assert_eq!(retries.max_retries, 4);
338        assert_eq!(retries.initial_delay_ms, 123);
339        assert_eq!(retries.backoff_factor, 3);
340    }
341
342    #[tokio::test]
343    async fn first_non_retryable_error_is_not_wrapped() {
344        let error = retry_with_exponential_backoff(
345            || async { Err::<(), _>(AiMuxError::InvalidArgument("bad".into())) },
346            2,
347            0,
348            2,
349            None,
350            should_retry,
351            unchanged_delay,
352        )
353        .await
354        .unwrap_err();
355        assert!(matches!(error, AiMuxError::InvalidArgument(_)));
356    }
357
358    #[tokio::test]
359    async fn zero_retries_returns_the_original_error() {
360        let attempts = AtomicUsize::new(0);
361        let error = retry_with_exponential_backoff(
362            || {
363                attempts.fetch_add(1, Ordering::SeqCst);
364                async { Err::<(), _>(api_error(true)) }
365            },
366            0,
367            0,
368            2,
369            None,
370            should_retry,
371            unchanged_delay,
372        )
373        .await
374        .unwrap_err();
375        assert!(matches!(error, AiMuxError::ApiCall(_)));
376        assert_eq!(attempts.load(Ordering::SeqCst), 1);
377    }
378
379    #[tokio::test]
380    async fn exhausted_attempts_preserve_the_complete_history() {
381        let attempts = AtomicUsize::new(0);
382        let error = retry_with_exponential_backoff(
383            || {
384                attempts.fetch_add(1, Ordering::SeqCst);
385                async { Err::<(), _>(api_error(true)) }
386            },
387            2,
388            0,
389            2,
390            None,
391            should_retry,
392            unchanged_delay,
393        )
394        .await
395        .unwrap_err();
396        let AiMuxError::Retry(error) = error else {
397            panic!("expected retry error")
398        };
399        assert_eq!(error.reason, RetryErrorReason::MaxRetriesExceeded);
400        assert_eq!(error.errors.len(), 3);
401        assert_eq!(attempts.load(Ordering::SeqCst), 3);
402    }
403
404    #[tokio::test]
405    async fn later_non_retryable_error_preserves_both_attempts() {
406        let attempts = AtomicUsize::new(0);
407        let error = retry_with_exponential_backoff(
408            || {
409                let attempt = attempts.fetch_add(1, Ordering::SeqCst);
410                async move { Err::<(), _>(api_error(attempt == 0)) }
411            },
412            2,
413            0,
414            2,
415            None,
416            should_retry,
417            unchanged_delay,
418        )
419        .await
420        .unwrap_err();
421        let AiMuxError::Retry(error) = error else {
422            panic!("expected retry error")
423        };
424        assert_eq!(error.reason, RetryErrorReason::ErrorNotRetryable);
425        assert_eq!(error.errors.len(), 2);
426    }
427
428    #[tokio::test]
429    async fn retry_error_is_never_nested() {
430        let inner = RetryError {
431            reason: RetryErrorReason::MaxRetriesExceeded,
432            errors: vec![api_error(true), api_error(true)],
433        };
434        let error = retry_with_exponential_backoff(
435            || {
436                let error = inner.clone();
437                async move { Err::<(), _>(AiMuxError::Retry(error)) }
438            },
439            2,
440            0,
441            2,
442            None,
443            should_retry,
444            unchanged_delay,
445        )
446        .await
447        .unwrap_err();
448
449        assert!(matches!(error, AiMuxError::Retry(_)));
450        let AiMuxError::Retry(error) = error else {
451            unreachable!()
452        };
453        assert!(
454            error
455                .errors
456                .iter()
457                .all(|error| !matches!(error, AiMuxError::Retry(_)))
458        );
459    }
460
461    #[tokio::test]
462    async fn timeout_and_abort_are_never_wrapped() {
463        for source in [
464            AiMuxError::Timeout("Total timeout of 1ms exceeded".into()),
465            AiMuxError::Aborted("request aborted".into()),
466        ] {
467            let expected = source.to_string();
468            let error = retry_with_exponential_backoff(
469                || {
470                    let source = source.clone();
471                    async move { Err::<(), _>(source) }
472                },
473                2,
474                0,
475                2,
476                None,
477                should_retry,
478                unchanged_delay,
479            )
480            .await
481            .unwrap_err();
482            assert_eq!(error.to_string(), expected);
483            assert!(!matches!(error, AiMuxError::Retry(_)));
484        }
485    }
486
487    #[tokio::test]
488    async fn max_retries_check_precedes_final_retryability() {
489        let attempts = AtomicUsize::new(0);
490        let error = retry_with_exponential_backoff(
491            || {
492                let attempt = attempts.fetch_add(1, Ordering::SeqCst);
493                async move { Err::<(), _>(api_error(attempt < 2)) }
494            },
495            2,
496            0,
497            2,
498            None,
499            should_retry,
500            unchanged_delay,
501        )
502        .await
503        .unwrap_err();
504        let AiMuxError::Retry(error) = error else {
505            panic!("expected retry error")
506        };
507        assert_eq!(error.reason, RetryErrorReason::MaxRetriesExceeded);
508        assert_eq!(error.errors.len(), 3);
509    }
510
511    #[test]
512    fn retry_headers_are_exact_and_only_exponential_delay_is_jittered() {
513        let milliseconds = api_error_with_headers(503, &[("retry-after-ms", "1500")]);
514        assert_eq!(retry_header_delay(&milliseconds, 2_000), Some(1_500));
515        assert_eq!(
516            retry_delay_with_jitter(&milliseconds, 2_000, |_| panic!("hint must not jitter")),
517            1_500
518        );
519
520        let seconds = api_error_with_headers(503, &[("retry-after", "1.25")]);
521        assert_eq!(retry_header_delay(&seconds, 2_000), Some(1_250));
522
523        let unreasonable = api_error_with_headers(503, &[("retry-after-ms", "120000")]);
524        assert_eq!(retry_header_delay(&unreasonable, 2_000), None);
525        assert_eq!(retry_header_delay(&unreasonable, u64::MAX), Some(120_000));
526        assert_eq!(retry_delay_with_jitter(&unreasonable, 2_000, |_| 0), 0);
527        assert_eq!(
528            retry_delay_with_jitter(&unreasonable, 2_000, |max| max),
529            2_000
530        );
531
532        let nan_then_seconds =
533            api_error_with_headers(503, &[("retry-after-ms", "NaN"), ("retry-after", "1")]);
534        assert_eq!(retry_header_delay(&nan_then_seconds, 2_000), Some(1_000));
535    }
536
537    #[tokio::test]
538    async fn abort_during_backoff_returns_abort_without_history() {
539        let signal = AbortSignal::new();
540        let abort_after_attempt = signal.clone();
541        let error = retry_with_exponential_backoff(
542            || async { Err::<(), _>(api_error(true)) },
543            2,
544            60_000,
545            2,
546            Some(&signal),
547            move |_error: &AiMuxError| {
548                abort_after_attempt.abort();
549                true
550            },
551            unchanged_delay,
552        )
553        .await
554        .unwrap_err();
555        assert!(matches!(error, AiMuxError::Aborted(message) if message == "request aborted"));
556    }
557
558    #[tokio::test]
559    async fn pre_aborted_signal_does_not_start_an_attempt() {
560        let signal = AbortSignal::new();
561        signal.abort();
562        let attempts = AtomicUsize::new(0);
563
564        let error = retry_with_exponential_backoff(
565            || {
566                attempts.fetch_add(1, Ordering::SeqCst);
567                async { Ok::<(), AiMuxError>(()) }
568            },
569            2,
570            0,
571            2,
572            Some(&signal),
573            should_retry,
574            unchanged_delay,
575        )
576        .await
577        .unwrap_err();
578
579        assert!(matches!(error, AiMuxError::Aborted(message) if message == "request aborted"));
580        assert_eq!(attempts.load(Ordering::SeqCst), 0);
581    }
582
583    #[tokio::test]
584    async fn abort_during_attempt_drops_the_attempt_future() {
585        struct DropMarker(Arc<AtomicUsize>);
586
587        impl Drop for DropMarker {
588            fn drop(&mut self) {
589                self.0.fetch_add(1, Ordering::SeqCst);
590            }
591        }
592
593        let signal = AbortSignal::new();
594        let dropped = Arc::new(AtomicUsize::new(0));
595        let marker = dropped.clone();
596        let future = retry_with_exponential_backoff(
597            move || {
598                let marker = DropMarker(marker.clone());
599                async move {
600                    let _marker = marker;
601                    std::future::pending::<Result<(), AiMuxError>>().await
602                }
603            },
604            2,
605            0,
606            2,
607            Some(&signal),
608            should_retry,
609            unchanged_delay,
610        );
611        tokio::pin!(future);
612        assert!(matches!(
613            futures::poll!(future.as_mut()),
614            std::task::Poll::Pending
615        ));
616
617        signal.abort();
618        let error = future.await.unwrap_err();
619
620        assert!(matches!(error, AiMuxError::Aborted(message) if message == "request aborted"));
621        assert_eq!(dropped.load(Ordering::SeqCst), 1);
622    }
623
624    #[test]
625    fn retry_error_messages_match_the_ai_sdk() {
626        let max = RetryError {
627            reason: RetryErrorReason::MaxRetriesExceeded,
628            errors: vec![api_error(true), api_error(true), api_error(true)],
629        };
630        assert_eq!(
631            max.to_string(),
632            "Failed after 3 attempts. Last error: API call error: retry"
633        );
634
635        let non_retryable = RetryError {
636            reason: RetryErrorReason::ErrorNotRetryable,
637            errors: vec![api_error(true), api_error(false)],
638        };
639        assert_eq!(
640            non_retryable.to_string(),
641            "Failed after 2 attempts with non-retryable error: 'API call error: retry'"
642        );
643    }
644}