1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct RetryConfig {
21 pub max_retries: u32,
23 pub initial_delay: Duration,
25 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#[derive(Debug, Clone)]
44pub struct PreparedRetries {
45 pub max_retries: u32,
47 initial_delay_ms: u64,
48 backoff_factor: u64,
49 abort_signal: Option<AbortSignal>,
50}
51
52#[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 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
119pub(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 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
223pub(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}