1use std::time::Duration;
25
26use reqwest::StatusCode;
27use serde::{Deserialize, Serialize};
28use tokio_util::sync::CancellationToken;
29
30const MAX_RETRY_ERROR_BODY_BYTES: usize = 4 * 1024;
31pub const MAX_RETRIES: u32 = 100;
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RetryConfig {
41 pub max_retries: u32,
43 pub base_delay_ms: u64,
45 pub max_delay_ms: u64,
47 pub retryable_status_codes: Vec<u16>,
49}
50
51impl Default for RetryConfig {
52 fn default() -> Self {
53 Self {
54 max_retries: 10,
55 base_delay_ms: 1000,
56 max_delay_ms: 30_000,
57 retryable_status_codes: vec![408, 429, 500, 502, 503, 504, 529],
58 }
59 }
60}
61
62impl RetryConfig {
63 pub fn disabled() -> Self {
65 Self {
66 max_retries: 0,
67 ..Default::default()
68 }
69 }
70
71 pub fn is_retryable_status(&self, status: StatusCode) -> bool {
73 self.retryable_status_codes.contains(&status.as_u16())
74 }
75
76 pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
81 let exp_delay = self.base_delay_ms.saturating_mul(1u64 << attempt.min(10));
82 let capped = exp_delay.min(self.max_delay_ms);
83
84 let jitter_range = capped / 4;
86 let jitter = if jitter_range > 0 {
87 let entropy = std::time::SystemTime::now()
90 .duration_since(std::time::UNIX_EPOCH)
91 .map(|d| d.subsec_nanos() as u64)
92 .unwrap_or(0);
93 let jitter_offset = (entropy ^ (attempt as u64).wrapping_mul(0x517cc1b727220a95))
94 % (jitter_range * 2 + 1);
95 capped - jitter_range + jitter_offset
96 } else {
97 capped
98 };
99
100 Duration::from_millis(jitter)
101 }
102
103 pub fn parse_retry_after(header_value: Option<&str>) -> Option<Duration> {
111 let value = header_value?.trim();
112 if let Ok(seconds) = value.parse::<f64>() {
114 if seconds > 0.0 && seconds <= 300.0 {
115 return Some(Duration::from_secs_f64(seconds));
116 }
117 }
118 None
119 }
120}
121
122#[derive(Debug)]
124pub enum AttemptOutcome<T> {
125 Success(T),
127 Retryable {
129 status: StatusCode,
130 body: String,
131 retry_after: Option<Duration>,
132 },
133 Fatal(anyhow::Error),
135}
136
137#[derive(Debug, thiserror::Error)]
142#[error("{terminal_message}")]
143pub struct RetryExhaustedError {
144 attempts: u32,
145 status: StatusCode,
146 body: String,
147 terminal_message: String,
148}
149
150impl RetryExhaustedError {
151 pub(crate) fn new(attempts: u32, status: StatusCode, body: impl Into<String>) -> Self {
152 let body = bound_retry_body(body.into());
153 let terminal_message = format!(
154 "LLM API request failed after {attempts} attempts. Last status: {status} Body: {body}"
155 );
156 Self {
157 attempts,
158 status,
159 body,
160 terminal_message,
161 }
162 }
163
164 pub fn from_status(attempts: u32, status: u16, body: impl Into<String>) -> Self {
167 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
168 Self::new(attempts, status, body)
169 }
170
171 pub(crate) fn status(&self) -> StatusCode {
172 self.status
173 }
174
175 pub fn attempts(&self) -> u32 {
177 self.attempts
178 }
179
180 pub fn status_code(&self) -> u16 {
183 self.status.as_u16()
184 }
185
186 pub fn body(&self) -> &str {
188 &self.body
189 }
190
191 pub(crate) fn non_retryable_message(&self) -> &str {
195 &self.terminal_message
196 }
197}
198
199fn bound_retry_body(body: String) -> String {
200 if body.len() <= MAX_RETRY_ERROR_BODY_BYTES {
201 return body;
202 }
203 let mut bounded = String::with_capacity(MAX_RETRY_ERROR_BODY_BYTES);
204 for character in body.chars() {
205 if bounded.len() + character.len_utf8() + 3 > MAX_RETRY_ERROR_BODY_BYTES {
206 break;
207 }
208 bounded.push(character);
209 }
210 bounded.push('…');
211 bounded
212}
213
214#[inline]
221fn total_attempts(max_retries: u32) -> u32 {
222 max_retries.saturating_add(1)
223}
224
225pub async fn with_retry<T, F, Fut>(config: &RetryConfig, operation: F) -> anyhow::Result<T>
231where
232 F: Fn(u32) -> Fut,
233 Fut: std::future::Future<Output = AttemptOutcome<T>>,
234{
235 with_retry_inner(config, None, operation).await
236}
237
238pub async fn with_retry_cancellable<T, F, Fut>(
242 config: &RetryConfig,
243 cancel_token: &CancellationToken,
244 operation: F,
245) -> anyhow::Result<T>
246where
247 F: Fn(u32) -> Fut,
248 Fut: std::future::Future<Output = AttemptOutcome<T>>,
249{
250 with_retry_inner(config, Some(cancel_token), operation).await
251}
252
253async fn with_retry_inner<T, F, Fut>(
254 config: &RetryConfig,
255 cancel_token: Option<&CancellationToken>,
256 operation: F,
257) -> anyhow::Result<T>
258where
259 F: Fn(u32) -> Fut,
260 Fut: std::future::Future<Output = AttemptOutcome<T>>,
261{
262 if config.max_retries > MAX_RETRIES {
263 anyhow::bail!(
264 "retry configuration max_retries={} exceeds the maximum of {}",
265 config.max_retries,
266 MAX_RETRIES
267 );
268 }
269
270 let mut last_status = None;
271 let mut last_body = String::new();
272
273 for attempt in 0..=config.max_retries {
274 if cancel_token.is_some_and(|token| token.is_cancelled()) {
275 anyhow::bail!("LLM retry cancelled");
276 }
277
278 match operation(attempt).await {
279 AttemptOutcome::Success(value) => {
280 if attempt > 0 {
281 tracing::info!("LLM API request succeeded after {} retries", attempt);
282 }
283 return Ok(value);
284 }
285 AttemptOutcome::Fatal(err) => {
286 return Err(err);
287 }
288 AttemptOutcome::Retryable {
289 status,
290 body,
291 retry_after,
292 } => {
293 last_status = Some(status);
294 last_body = body;
295
296 if attempt < config.max_retries {
297 let delay = retry_after.unwrap_or_else(|| config.delay_for_attempt(attempt));
299
300 tracing::warn!(
301 "LLM API request failed with {} (attempt {}/{}), retrying in {:?}",
302 status,
303 attempt.saturating_add(1),
304 total_attempts(config.max_retries),
305 delay,
306 );
307
308 if let Some(cancel_token) = cancel_token {
309 tokio::select! {
310 _ = cancel_token.cancelled() => anyhow::bail!("LLM retry cancelled"),
311 _ = tokio::time::sleep(delay) => {}
312 }
313 } else {
314 tokio::time::sleep(delay).await;
315 }
316 }
317 }
318 }
319 }
320
321 let status = last_status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
323 Err(anyhow::Error::new(RetryExhaustedError::new(
324 total_attempts(config.max_retries),
325 status,
326 last_body,
327 )))
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use std::sync::atomic::{AtomicU32, Ordering};
334 use std::sync::Arc;
335
336 #[tokio::test]
337 async fn retry_exhaustion_preserves_typed_http_status() {
338 let config = RetryConfig {
339 max_retries: 0,
340 ..RetryConfig::default()
341 };
342 let error = with_retry::<(), _, _>(&config, |_| async {
343 AttemptOutcome::Retryable {
344 status: StatusCode::TOO_MANY_REQUESTS,
345 body: "opaque provider response".to_string(),
346 retry_after: None,
347 }
348 })
349 .await
350 .expect_err("the only attempt must fail");
351
352 let exhausted = error
353 .downcast_ref::<RetryExhaustedError>()
354 .expect("retry exhaustion must retain its typed status");
355 assert_eq!(exhausted.status(), StatusCode::TOO_MANY_REQUESTS);
356 }
357
358 #[test]
359 fn retry_exhaustion_bounds_provider_body_without_losing_status() {
360 let error = RetryExhaustedError::new(
361 2,
362 StatusCode::SERVICE_UNAVAILABLE,
363 "错误".repeat(MAX_RETRY_ERROR_BODY_BYTES),
364 );
365 assert!(error.to_string().len() <= MAX_RETRY_ERROR_BODY_BYTES + 128);
366 assert!(error.to_string().contains("503"));
367 assert!(error.to_string().ends_with('…'));
368 }
369
370 #[test]
371 fn total_attempts_saturates_at_u32_max() {
372 assert_eq!(total_attempts(0), 1);
373 assert_eq!(total_attempts(10), 11);
374 assert_eq!(total_attempts(u32::MAX), u32::MAX);
375 }
376
377 #[tokio::test]
378 async fn excessive_retry_budget_is_rejected_before_provider_use() {
379 let config = RetryConfig {
380 max_retries: MAX_RETRIES + 1,
381 ..RetryConfig::default()
382 };
383 let calls = Arc::new(AtomicU32::new(0));
384 let calls_for_operation = Arc::clone(&calls);
385 let error = with_retry::<(), _, _>(&config, move |_| {
386 calls_for_operation.fetch_add(1, Ordering::Relaxed);
387 async { AttemptOutcome::Success(()) }
388 })
389 .await
390 .expect_err("an excessive retry budget must fail closed");
391
392 assert_eq!(calls.load(Ordering::Relaxed), 0);
393 assert!(error.to_string().contains("exceeds the maximum"));
394 }
395
396 #[test]
401 fn test_retry_config_default() {
402 let config = RetryConfig::default();
403 assert_eq!(config.max_retries, 10);
404 assert_eq!(config.base_delay_ms, 1000);
405 assert_eq!(config.max_delay_ms, 30_000);
406 assert_eq!(
407 config.retryable_status_codes,
408 vec![408, 429, 500, 502, 503, 504, 529]
409 );
410 }
411
412 #[test]
413 fn test_retry_config_disabled() {
414 let config = RetryConfig::disabled();
415 assert_eq!(config.max_retries, 0);
416 }
417
418 #[test]
419 fn test_is_retryable_status() {
420 let config = RetryConfig::default();
421 assert!(config.is_retryable_status(StatusCode::REQUEST_TIMEOUT)); assert!(config.is_retryable_status(StatusCode::TOO_MANY_REQUESTS)); assert!(config.is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR)); assert!(config.is_retryable_status(StatusCode::BAD_GATEWAY)); assert!(config.is_retryable_status(StatusCode::SERVICE_UNAVAILABLE)); assert!(config.is_retryable_status(StatusCode::GATEWAY_TIMEOUT)); assert!(config.is_retryable_status(StatusCode::from_u16(529).unwrap()));
429
430 assert!(!config.is_retryable_status(StatusCode::OK)); assert!(!config.is_retryable_status(StatusCode::BAD_REQUEST)); assert!(!config.is_retryable_status(StatusCode::UNAUTHORIZED)); assert!(!config.is_retryable_status(StatusCode::FORBIDDEN)); assert!(!config.is_retryable_status(StatusCode::NOT_FOUND)); }
437
438 #[test]
439 fn test_delay_for_attempt_exponential() {
440 let config = RetryConfig {
441 base_delay_ms: 1000,
442 max_delay_ms: 60_000,
443 ..Default::default()
444 };
445
446 let d0 = config.delay_for_attempt(0);
448 assert!(d0.as_millis() >= 750 && d0.as_millis() <= 1250);
449
450 let d1 = config.delay_for_attempt(1);
452 assert!(d1.as_millis() >= 1500 && d1.as_millis() <= 2500);
453
454 let d2 = config.delay_for_attempt(2);
456 assert!(d2.as_millis() >= 3000 && d2.as_millis() <= 5000);
457 }
458
459 #[test]
460 fn test_delay_capped_at_max() {
461 let config = RetryConfig {
462 base_delay_ms: 1000,
463 max_delay_ms: 5000,
464 ..Default::default()
465 };
466
467 let d = config.delay_for_attempt(10);
469 assert!(d.as_millis() <= 6250); }
471
472 #[test]
473 fn test_delay_zero_base() {
474 let config = RetryConfig {
475 base_delay_ms: 0,
476 max_delay_ms: 1000,
477 ..Default::default()
478 };
479 let d = config.delay_for_attempt(0);
480 assert_eq!(d.as_millis(), 0);
481 }
482
483 #[test]
488 fn test_parse_retry_after_integer() {
489 let d = RetryConfig::parse_retry_after(Some("5"));
490 assert_eq!(d, Some(Duration::from_secs(5)));
491 }
492
493 #[test]
494 fn test_parse_retry_after_decimal() {
495 let d = RetryConfig::parse_retry_after(Some("1.5"));
496 assert_eq!(d, Some(Duration::from_secs_f64(1.5)));
497 }
498
499 #[test]
500 fn test_parse_retry_after_none() {
501 assert_eq!(RetryConfig::parse_retry_after(None), None);
502 }
503
504 #[test]
505 fn test_parse_retry_after_invalid() {
506 assert_eq!(RetryConfig::parse_retry_after(Some("not-a-number")), None);
507 }
508
509 #[test]
510 fn test_parse_retry_after_negative() {
511 assert_eq!(RetryConfig::parse_retry_after(Some("-1")), None);
512 }
513
514 #[test]
515 fn test_parse_retry_after_zero() {
516 assert_eq!(RetryConfig::parse_retry_after(Some("0")), None);
517 }
518
519 #[test]
520 fn test_parse_retry_after_too_large() {
521 assert_eq!(RetryConfig::parse_retry_after(Some("301")), None);
523 }
524
525 #[test]
526 fn test_parse_retry_after_with_whitespace() {
527 let d = RetryConfig::parse_retry_after(Some(" 3 "));
528 assert_eq!(d, Some(Duration::from_secs(3)));
529 }
530
531 #[test]
536 fn test_retry_config_serde_roundtrip() {
537 let config = RetryConfig::default();
538 let json = serde_json::to_string(&config).unwrap();
539 let deserialized: RetryConfig = serde_json::from_str(&json).unwrap();
540 assert_eq!(deserialized.max_retries, config.max_retries);
541 assert_eq!(deserialized.base_delay_ms, config.base_delay_ms);
542 assert_eq!(deserialized.max_delay_ms, config.max_delay_ms);
543 assert_eq!(
544 deserialized.retryable_status_codes,
545 config.retryable_status_codes
546 );
547 }
548
549 #[test]
550 fn test_retry_config_deserialize_custom() {
551 let json = r#"{"max_retries":5,"base_delay_ms":500,"max_delay_ms":10000,"retryable_status_codes":[429,503]}"#;
552 let config: RetryConfig = serde_json::from_str(json).unwrap();
553 assert_eq!(config.max_retries, 5);
554 assert_eq!(config.base_delay_ms, 500);
555 assert_eq!(config.max_delay_ms, 10_000);
556 assert_eq!(config.retryable_status_codes, vec![429, 503]);
557 }
558
559 #[tokio::test]
564 async fn test_with_retry_success_first_attempt() {
565 let config = RetryConfig::default();
566 let call_count = Arc::new(AtomicU32::new(0));
567 let cc = call_count.clone();
568
569 let result = with_retry(&config, |_attempt| {
570 let cc = cc.clone();
571 async move {
572 cc.fetch_add(1, Ordering::SeqCst);
573 AttemptOutcome::Success("ok")
574 }
575 })
576 .await;
577
578 assert!(result.is_ok());
579 assert_eq!(result.unwrap(), "ok");
580 assert_eq!(call_count.load(Ordering::SeqCst), 1);
581 }
582
583 #[tokio::test]
584 async fn test_with_retry_success_after_retries() {
585 let config = RetryConfig {
586 max_retries: 3,
587 base_delay_ms: 10, max_delay_ms: 50,
589 ..Default::default()
590 };
591 let call_count = Arc::new(AtomicU32::new(0));
592 let cc = call_count.clone();
593
594 let result = with_retry(&config, |attempt| {
595 let cc = cc.clone();
596 async move {
597 cc.fetch_add(1, Ordering::SeqCst);
598 if attempt < 2 {
599 AttemptOutcome::Retryable {
600 status: StatusCode::TOO_MANY_REQUESTS,
601 body: "rate limited".to_string(),
602 retry_after: None,
603 }
604 } else {
605 AttemptOutcome::Success("recovered")
606 }
607 }
608 })
609 .await;
610
611 assert!(result.is_ok());
612 assert_eq!(result.unwrap(), "recovered");
613 assert_eq!(call_count.load(Ordering::SeqCst), 3); }
615
616 #[tokio::test]
617 async fn test_with_retry_all_retries_exhausted() {
618 let config = RetryConfig {
619 max_retries: 2,
620 base_delay_ms: 10,
621 max_delay_ms: 50,
622 ..Default::default()
623 };
624 let call_count = Arc::new(AtomicU32::new(0));
625 let cc = call_count.clone();
626
627 let result: anyhow::Result<&str> = with_retry(&config, |_attempt| {
628 let cc = cc.clone();
629 async move {
630 cc.fetch_add(1, Ordering::SeqCst);
631 AttemptOutcome::Retryable {
632 status: StatusCode::SERVICE_UNAVAILABLE,
633 body: "service down".to_string(),
634 retry_after: None,
635 }
636 }
637 })
638 .await;
639
640 assert!(result.is_err());
641 let err = result.unwrap_err().to_string();
642 assert!(err.contains("3 attempts")); assert!(err.contains("503"));
644 assert!(err.contains("service down"));
645 assert_eq!(call_count.load(Ordering::SeqCst), 3);
646 }
647
648 #[tokio::test]
649 async fn test_with_retry_default_budget_is_ten_retries() {
650 let config = RetryConfig::default();
651 let call_count = Arc::new(AtomicU32::new(0));
652 let cc = call_count.clone();
653
654 let result: anyhow::Result<&str> = with_retry(&config, |_attempt| {
655 let cc = cc.clone();
656 async move {
657 cc.fetch_add(1, Ordering::SeqCst);
658 AttemptOutcome::Retryable {
659 status: StatusCode::GATEWAY_TIMEOUT,
660 body: "gateway timeout".to_string(),
661 retry_after: Some(Duration::from_millis(0)),
662 }
663 }
664 })
665 .await;
666
667 assert!(result.is_err());
668 assert_eq!(
669 call_count.load(Ordering::SeqCst),
670 11,
671 "default max_retries=10 means one initial attempt plus ten retries"
672 );
673 assert!(result.unwrap_err().to_string().contains("11 attempts"));
674 }
675
676 #[tokio::test]
677 async fn test_with_retry_fatal_error_no_retry() {
678 let config = RetryConfig {
679 max_retries: 3,
680 base_delay_ms: 10,
681 max_delay_ms: 50,
682 ..Default::default()
683 };
684 let call_count = Arc::new(AtomicU32::new(0));
685 let cc = call_count.clone();
686
687 let result: anyhow::Result<&str> = with_retry(&config, |_attempt| {
688 let cc = cc.clone();
689 async move {
690 cc.fetch_add(1, Ordering::SeqCst);
691 AttemptOutcome::Fatal(anyhow::anyhow!("invalid API key"))
692 }
693 })
694 .await;
695
696 assert!(result.is_err());
697 assert!(result.unwrap_err().to_string().contains("invalid API key"));
698 assert_eq!(call_count.load(Ordering::SeqCst), 1); }
700
701 #[tokio::test]
702 async fn test_with_retry_disabled() {
703 let config = RetryConfig::disabled();
704 let call_count = Arc::new(AtomicU32::new(0));
705 let cc = call_count.clone();
706
707 let result: anyhow::Result<&str> = with_retry(&config, |_attempt| {
708 let cc = cc.clone();
709 async move {
710 cc.fetch_add(1, Ordering::SeqCst);
711 AttemptOutcome::Retryable {
712 status: StatusCode::TOO_MANY_REQUESTS,
713 body: "rate limited".to_string(),
714 retry_after: None,
715 }
716 }
717 })
718 .await;
719
720 assert!(result.is_err());
721 assert_eq!(call_count.load(Ordering::SeqCst), 1); }
723
724 #[tokio::test]
725 async fn test_with_retry_respects_retry_after_header() {
726 let config = RetryConfig {
727 max_retries: 1,
728 base_delay_ms: 10,
729 max_delay_ms: 50,
730 ..Default::default()
731 };
732 let call_count = Arc::new(AtomicU32::new(0));
733 let cc = call_count.clone();
734
735 let start = tokio::time::Instant::now();
736 let result = with_retry(&config, |attempt| {
737 let cc = cc.clone();
738 async move {
739 cc.fetch_add(1, Ordering::SeqCst);
740 if attempt == 0 {
741 AttemptOutcome::Retryable {
742 status: StatusCode::TOO_MANY_REQUESTS,
743 body: "rate limited".to_string(),
744 retry_after: Some(Duration::from_millis(100)),
745 }
746 } else {
747 AttemptOutcome::Success("ok")
748 }
749 }
750 })
751 .await;
752
753 assert!(result.is_ok());
754 assert!(start.elapsed() >= Duration::from_millis(90));
756 assert_eq!(call_count.load(Ordering::SeqCst), 2);
757 }
758
759 #[tokio::test]
760 async fn cancellable_retry_stops_during_retry_after_backoff() {
761 let config = RetryConfig {
762 max_retries: 1,
763 base_delay_ms: 0,
764 max_delay_ms: 0,
765 ..Default::default()
766 };
767 let cancel_token = CancellationToken::new();
768 let call_count = Arc::new(AtomicU32::new(0));
769 let count = call_count.clone();
770 let cancellation = cancel_token.clone();
771
772 let task = tokio::spawn(async move {
773 with_retry_cancellable::<(), _, _>(&config, &cancellation, |_attempt| {
774 let count = count.clone();
775 async move {
776 count.fetch_add(1, Ordering::SeqCst);
777 AttemptOutcome::Retryable {
778 status: StatusCode::TOO_MANY_REQUESTS,
779 body: "rate limited".to_string(),
780 retry_after: Some(Duration::from_secs(300)),
781 }
782 }
783 })
784 .await
785 });
786
787 tokio::time::sleep(Duration::from_millis(10)).await;
788 cancel_token.cancel();
789 let error = tokio::time::timeout(Duration::from_secs(1), task)
790 .await
791 .expect("cancellation must interrupt a long Retry-After wait")
792 .expect("retry task must not panic")
793 .expect_err("cancelled retry must fail");
794
795 assert_eq!(error.to_string(), "LLM retry cancelled");
796 assert_eq!(call_count.load(Ordering::SeqCst), 1);
797 }
798}