Skip to main content

a3s_code_core/
retry.rs

1//! Retry logic for LLM API calls
2//!
3//! Provides exponential backoff with jitter for transient HTTP errors.
4//! Supports `Retry-After` header parsing for rate-limited responses.
5//!
6//! ## Retryable Status Codes
7//!
8//! - 408: Request Timeout
9//! - 429: Too Many Requests (rate limited)
10//! - 500: Internal Server Error
11//! - 502: Bad Gateway
12//! - 503: Service Unavailable
13//! - 504: Gateway Timeout
14//! - 529: Overloaded (Anthropic-specific)
15//!
16//! ## Usage
17//!
18//! ```rust,ignore
19//! use a3s_code::retry::RetryConfig;
20//!
21//! let config = RetryConfig::default(); // 10 retries, 1s base, 30s max
22//! ```
23
24use 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;
31/// Hard upper bound for provider retries accepted by the Core loop.
32///
33/// Retry configuration can be supplied by a host, so allowing an arbitrary
34/// `u32` here would turn a transient provider failure into an effectively
35/// unbounded resource reservation.
36pub const MAX_RETRIES: u32 = 100;
37
38/// Configuration for API retry behavior
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RetryConfig {
41    /// Maximum number of retry attempts (0 = no retries)
42    pub max_retries: u32,
43    /// Base delay in milliseconds for exponential backoff
44    pub base_delay_ms: u64,
45    /// Maximum delay in milliseconds (cap for exponential growth)
46    pub max_delay_ms: u64,
47    /// HTTP status codes that trigger a retry
48    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    /// Create a retry config with no retries (disabled)
64    pub fn disabled() -> Self {
65        Self {
66            max_retries: 0,
67            ..Default::default()
68        }
69    }
70
71    /// Check if a given HTTP status code is retryable
72    pub fn is_retryable_status(&self, status: StatusCode) -> bool {
73        self.retryable_status_codes.contains(&status.as_u16())
74    }
75
76    /// Calculate the delay for a given attempt number (0-indexed)
77    ///
78    /// Uses exponential backoff: `base_delay * 2^attempt`, capped at `max_delay`.
79    /// Adds jitter of ±25% to avoid thundering herd.
80    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        // Add jitter: ±25%
85        let jitter_range = capped / 4;
86        let jitter = if jitter_range > 0 {
87            // Use system time nanos + attempt as entropy for non-deterministic jitter,
88            // so different clients/attempts spread their retries to avoid thundering herd.
89            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    /// Parse `Retry-After` header value to get a delay duration.
104    ///
105    /// Supports:
106    /// - Integer seconds (e.g., "5")
107    /// - Decimal seconds (e.g., "1.5")
108    ///
109    /// Returns `None` if the header is missing or unparseable.
110    pub fn parse_retry_after(header_value: Option<&str>) -> Option<Duration> {
111        let value = header_value?.trim();
112        // Try parsing as float seconds (covers both "5" and "1.5")
113        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/// Outcome of a single HTTP attempt, used by the retry loop
123#[derive(Debug)]
124pub enum AttemptOutcome<T> {
125    /// Request succeeded
126    Success(T),
127    /// Request failed with a retryable error
128    Retryable {
129        status: StatusCode,
130        body: String,
131        retry_after: Option<Duration>,
132    },
133    /// Request failed with a non-retryable error (bail immediately)
134    Fatal(anyhow::Error),
135}
136
137/// A retry loop that exhausted a typed HTTP response status.
138///
139/// The rendered body is diagnostic only; callers use the retained status for
140/// policy decisions.
141#[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    /// Construct a typed exhaustion value from a wire status for host-side
165    /// adapters and tests. Invalid status values conservatively map to 500.
166    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    /// Number of provider attempts consumed before retry authority stopped.
176    pub fn attempts(&self) -> u32 {
177        self.attempts
178    }
179
180    /// Last provider HTTP status, exposed without requiring callers to depend
181    /// on the internal retry module or parse the rendered diagnostic.
182    pub fn status_code(&self) -> u16 {
183        self.status.as_u16()
184    }
185
186    /// Bounded last provider response body retained for diagnostics.
187    pub fn body(&self) -> &str {
188        &self.body
189    }
190
191    /// A stable marker consumed by the outer Agent boundary. Once this retry
192    /// authority has exhausted its budget, replaying the same request through
193    /// another fallback or circuit breaker only repeats the same side effect.
194    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/// Return the total number of provider attempts represented by a retry budget.
215///
216/// A retry budget is expressed as retries after the initial request. Keep the
217/// diagnostic projection total even for a hostile `u32::MAX` configuration;
218/// overflowing here would turn a bounded accounting value into zero (or panic
219/// in debug builds) while the retry loop is still handling its terminal path.
220#[inline]
221fn total_attempts(max_retries: u32) -> u32 {
222    max_retries.saturating_add(1)
223}
224
225/// Execute an async operation with retry logic.
226///
227/// The `operation` closure is called on each attempt and must return an `AttemptOutcome`.
228/// On retryable failures, waits with exponential backoff before retrying.
229/// On fatal failures or after exhausting retries, returns an error.
230pub 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
238/// Execute an async operation with retry logic that can be interrupted while
239/// waiting for a provider-directed backoff. The ordinary [`with_retry`] API is
240/// intentionally retained for callers without a cancellation scope.
241pub 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                    // Determine delay: prefer Retry-After header, fallback to exponential backoff
298                    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    // All retries exhausted
322    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    // ========================================================================
397    // RetryConfig unit tests
398    // ========================================================================
399
400    #[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)); // 408
422        assert!(config.is_retryable_status(StatusCode::TOO_MANY_REQUESTS)); // 429
423        assert!(config.is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR)); // 500
424        assert!(config.is_retryable_status(StatusCode::BAD_GATEWAY)); // 502
425        assert!(config.is_retryable_status(StatusCode::SERVICE_UNAVAILABLE)); // 503
426        assert!(config.is_retryable_status(StatusCode::GATEWAY_TIMEOUT)); // 504
427                                                                          // 529 is not a standard StatusCode, test via from_u16
428        assert!(config.is_retryable_status(StatusCode::from_u16(529).unwrap()));
429
430        // Non-retryable
431        assert!(!config.is_retryable_status(StatusCode::OK)); // 200
432        assert!(!config.is_retryable_status(StatusCode::BAD_REQUEST)); // 400
433        assert!(!config.is_retryable_status(StatusCode::UNAUTHORIZED)); // 401
434        assert!(!config.is_retryable_status(StatusCode::FORBIDDEN)); // 403
435        assert!(!config.is_retryable_status(StatusCode::NOT_FOUND)); // 404
436    }
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        // Attempt 0: ~1000ms (with jitter)
447        let d0 = config.delay_for_attempt(0);
448        assert!(d0.as_millis() >= 750 && d0.as_millis() <= 1250);
449
450        // Attempt 1: ~2000ms (with jitter)
451        let d1 = config.delay_for_attempt(1);
452        assert!(d1.as_millis() >= 1500 && d1.as_millis() <= 2500);
453
454        // Attempt 2: ~4000ms (with jitter)
455        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        // Attempt 10 would be 1024s without cap, should be capped at 5s
468        let d = config.delay_for_attempt(10);
469        assert!(d.as_millis() <= 6250); // 5000 + 25% jitter
470    }
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    // ========================================================================
484    // Retry-After header parsing
485    // ========================================================================
486
487    #[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        // > 300s should be rejected
522        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    // ========================================================================
532    // RetryConfig serialization
533    // ========================================================================
534
535    #[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    // ========================================================================
560    // with_retry integration tests
561    // ========================================================================
562
563    #[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, // Fast for tests
588            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); // 2 failures + 1 success
614    }
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")); // max_retries(2) + 1
643        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); // No retries for fatal
699    }
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); // Only initial attempt, no retries
722    }
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        // Should have waited at least 100ms (the retry-after value)
755        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}