Skip to main content

adk_model/
retry.rs

1use adk_core::{AdkError, ErrorCategory, Result};
2use std::{future::Future, time::Duration};
3
4/// Configuration for automatic retry with exponential backoff.
5#[derive(Clone, Debug)]
6pub struct RetryConfig {
7    /// Whether retries are enabled.
8    pub enabled: bool,
9    /// Maximum number of retry attempts before giving up.
10    pub max_retries: u32,
11    /// Initial delay before the first retry.
12    pub initial_delay: Duration,
13    /// Maximum delay between retries. Caps both exponential growth and a
14    /// server-provided `Retry-After` delay, so a large `Retry-After` never
15    /// stalls an agent run for longer than this.
16    pub max_delay: Duration,
17    /// Multiplier applied to the delay after each retry.
18    pub backoff_multiplier: f32,
19}
20
21impl Default for RetryConfig {
22    fn default() -> Self {
23        Self {
24            enabled: true,
25            max_retries: 3,
26            initial_delay: Duration::from_millis(250),
27            max_delay: Duration::from_secs(5),
28            backoff_multiplier: 2.0,
29        }
30    }
31}
32
33impl RetryConfig {
34    /// Create a disabled retry configuration (no retries).
35    #[must_use]
36    pub fn disabled() -> Self {
37        Self { enabled: false, ..Self::default() }
38    }
39
40    /// Set the maximum number of retry attempts.
41    #[must_use]
42    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
43        self.max_retries = max_retries;
44        self
45    }
46
47    /// Set the initial delay before the first retry.
48    #[must_use]
49    pub fn with_initial_delay(mut self, initial_delay: Duration) -> Self {
50        self.initial_delay = initial_delay;
51        self
52    }
53
54    /// Set the maximum delay between retries.
55    #[must_use]
56    pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
57        self.max_delay = max_delay;
58        self
59    }
60
61    /// Set the backoff multiplier applied after each retry.
62    #[must_use]
63    pub fn with_backoff_multiplier(mut self, backoff_multiplier: f32) -> Self {
64        self.backoff_multiplier = backoff_multiplier;
65        self
66    }
67}
68
69/// Returns `true` if the HTTP status code indicates a transient error worth retrying.
70#[must_use]
71pub fn is_retryable_status_code(status_code: u16) -> bool {
72    matches!(status_code, 408 | 429 | 500 | 502 | 503 | 504 | 529)
73}
74
75/// Maps an upstream HTTP error status to the [`ErrorCategory`] provider clients report.
76///
77/// The transient server statuses (500, 502, 503, 504, and Anthropic's 529) map
78/// to [`ErrorCategory::Unavailable`], which [`execute_with_retry`] retries; every
79/// supported provider documents them as safe to retry after a short wait.
80///
81/// # Example
82///
83/// ```rust
84/// use adk_core::ErrorCategory;
85/// use adk_model::retry::category_for_status_code;
86///
87/// assert_eq!(category_for_status_code(502), ErrorCategory::Unavailable);
88/// assert_eq!(category_for_status_code(429), ErrorCategory::RateLimited);
89/// assert_eq!(category_for_status_code(400), ErrorCategory::InvalidInput);
90/// ```
91#[must_use]
92pub fn category_for_status_code(status_code: u16) -> ErrorCategory {
93    match status_code {
94        401 => ErrorCategory::Unauthorized,
95        403 => ErrorCategory::Forbidden,
96        404 => ErrorCategory::NotFound,
97        408 => ErrorCategory::Timeout,
98        429 => ErrorCategory::RateLimited,
99        500 | 502 | 503 | 504 | 529 => ErrorCategory::Unavailable,
100        500..=599 => ErrorCategory::Internal,
101        _ => ErrorCategory::InvalidInput,
102    }
103}
104
105/// Parses a `Retry-After` header value given in seconds.
106///
107/// Returns `None` for an HTTP-date or any other value that is not a
108/// non-negative number of seconds.
109///
110/// # Example
111///
112/// ```rust
113/// use adk_model::retry::parse_retry_after;
114/// use std::time::Duration;
115///
116/// assert_eq!(parse_retry_after("7"), Some(Duration::from_secs(7)));
117/// assert_eq!(parse_retry_after("1.5"), Some(Duration::from_millis(1500)));
118/// assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
119/// ```
120#[must_use]
121pub fn parse_retry_after(value: &str) -> Option<Duration> {
122    let seconds = value.trim().parse::<f64>().ok()?;
123    Duration::try_from_secs_f64(seconds).ok()
124}
125
126/// Attaches a server-provided `Retry-After` delay to a retryable error.
127///
128/// Errors in a non-retryable category are returned unchanged.
129///
130/// # Example
131///
132/// ```rust
133/// use adk_core::{AdkError, ErrorCategory, ErrorComponent};
134/// use adk_model::retry::with_retry_after;
135/// use std::time::Duration;
136///
137/// let error = AdkError::new(ErrorComponent::Model, ErrorCategory::Unavailable, "m.e", "down");
138/// let error = with_retry_after(error, Some(Duration::from_secs(2)));
139/// assert_eq!(error.retry.retry_after(), Some(Duration::from_secs(2)));
140/// ```
141#[must_use]
142pub fn with_retry_after(error: AdkError, retry_after: Option<Duration>) -> AdkError {
143    match retry_after {
144        Some(delay) if error.retry.should_retry => {
145            let retry = error.retry.clone().with_retry_after(delay);
146            error.with_retry(retry)
147        }
148        _ => error,
149    }
150}
151
152/// Returns `true` if the error message contains patterns indicating a transient failure.
153#[must_use]
154pub fn is_retryable_error_message(message: &str) -> bool {
155    let normalized = message.to_ascii_uppercase();
156    normalized.contains("429")
157        || normalized.contains("408")
158        || normalized.contains("500")
159        || normalized.contains("502")
160        || normalized.contains("503")
161        || normalized.contains("504")
162        || normalized.contains("529")
163        || normalized.contains("RATE LIMIT")
164        || normalized.contains("TOO MANY REQUESTS")
165        || normalized.contains("RESOURCE_EXHAUSTED")
166        || normalized.contains("UNAVAILABLE")
167        || normalized.contains("DEADLINE_EXCEEDED")
168        || normalized.contains("TIMEOUT")
169        || normalized.contains("TIMED OUT")
170        || normalized.contains("CONNECTION RESET")
171        || normalized.contains("OVERLOADED")
172}
173
174/// Returns `true` if the `AdkError` should be retried based on its retry hint or message.
175#[must_use]
176pub fn is_retryable_model_error(error: &AdkError) -> bool {
177    // Primary: use structured retry hint (single source of truth)
178    if error.retry.should_retry {
179        return true;
180    }
181    // Fallback: for backward-compat `.legacy` errors during transition,
182    // check the error message for retryable patterns
183    if error.code.ends_with(".legacy") && error.is_model() {
184        return is_retryable_error_message(&error.message);
185    }
186    false
187}
188
189fn next_retry_delay(current: Duration, retry_config: &RetryConfig) -> Duration {
190    if current >= retry_config.max_delay {
191        return retry_config.max_delay;
192    }
193
194    let multiplier = retry_config.backoff_multiplier.max(1.0) as f64;
195    let scaled = Duration::from_secs_f64(current.as_secs_f64() * multiplier);
196    scaled.min(retry_config.max_delay)
197}
198
199/// Hint from the server about when to retry.
200///
201/// When the server provides a `retry-after` header, this hint overrides the
202/// exponential backoff calculation for the next retry attempt.
203///
204/// # Example
205///
206/// ```rust
207/// use adk_model::retry::ServerRetryHint;
208/// use std::time::Duration;
209///
210/// let hint = ServerRetryHint { retry_after: Some(Duration::from_secs(30)) };
211/// assert_eq!(hint.retry_after, Some(Duration::from_secs(30)));
212/// ```
213#[derive(Debug, Clone, Default)]
214pub struct ServerRetryHint {
215    /// Server-suggested delay before retrying.
216    pub retry_after: Option<Duration>,
217}
218
219/// Execute an operation with automatic retry on transient errors.
220pub async fn execute_with_retry<T, Op, Fut, Classify>(
221    retry_config: &RetryConfig,
222    classify_error: Classify,
223    mut operation: Op,
224) -> Result<T>
225where
226    Op: FnMut() -> Fut,
227    Fut: Future<Output = Result<T>>,
228    Classify: Fn(&AdkError) -> bool,
229{
230    execute_with_retry_hint(retry_config, classify_error, None, &mut operation).await
231}
232
233/// Execute an operation with retry logic, optionally using a server-provided
234/// retry hint to override the backoff delay.
235///
236/// When `server_hint` contains a `retry_after` duration, that duration is used
237/// instead of the exponential backoff calculation. This respects server-provided
238/// timing from `retry-after` headers (Requirement 5.1).
239///
240/// A server-provided delay, from the error or from `server_hint`, is capped at
241/// [`RetryConfig::max_delay`].
242pub async fn execute_with_retry_hint<T, Op, Fut, Classify>(
243    retry_config: &RetryConfig,
244    classify_error: Classify,
245    server_hint: Option<&ServerRetryHint>,
246    operation: &mut Op,
247) -> Result<T>
248where
249    Op: FnMut() -> Fut,
250    Fut: Future<Output = Result<T>>,
251    Classify: Fn(&AdkError) -> bool,
252{
253    if !retry_config.enabled {
254        return operation().await;
255    }
256
257    let mut attempt: u32 = 0;
258    let mut delay = retry_config.initial_delay;
259
260    // If the server provided a retry-after hint, use it for the first retry delay.
261    let server_delay = server_hint.and_then(|h| h.retry_after);
262
263    loop {
264        match operation().await {
265            Ok(value) => return Ok(value),
266            Err(error) if attempt < retry_config.max_retries && classify_error(&error) => {
267                attempt += 1;
268
269                // Priority: 1) structured retry_after from AdkError, 2) server hint, 3) backoff.
270                // A server delay is capped so `Retry-After: 3600` cannot stall the run.
271                let error_retry_after = error.retry.retry_after();
272                let effective_delay = if let Some(d) = error_retry_after {
273                    d.min(retry_config.max_delay)
274                } else if attempt == 1 {
275                    server_delay.map_or(delay, |d| d.min(retry_config.max_delay))
276                } else {
277                    delay
278                };
279
280                adk_telemetry::warn!(
281                    attempt = attempt,
282                    max_retries = retry_config.max_retries,
283                    delay_ms = effective_delay.as_millis(),
284                    error.code = error.code,
285                    error = %error,
286                    "Provider request failed with retryable error; retrying"
287                );
288                tokio::time::sleep(effective_delay).await;
289                delay = next_retry_delay(delay, retry_config);
290            }
291            Err(error) => return Err(error),
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::sync::{
300        Arc,
301        atomic::{AtomicU32, Ordering},
302    };
303
304    #[tokio::test]
305    async fn execute_with_retry_retries_when_classified_retryable() {
306        let retry_config = RetryConfig::default()
307            .with_max_retries(2)
308            .with_initial_delay(Duration::ZERO)
309            .with_max_delay(Duration::ZERO);
310        let attempts = Arc::new(AtomicU32::new(0));
311
312        let result = execute_with_retry(&retry_config, is_retryable_model_error, || {
313            let attempts = Arc::clone(&attempts);
314            async move {
315                let attempt = attempts.fetch_add(1, Ordering::SeqCst);
316                if attempt < 2 {
317                    return Err(AdkError::model("HTTP 429 rate limit"));
318                }
319                Ok("ok")
320            }
321        })
322        .await
323        .expect("operation should succeed after retries");
324
325        assert_eq!(result, "ok");
326        assert_eq!(attempts.load(Ordering::SeqCst), 3);
327    }
328
329    #[tokio::test]
330    async fn execute_with_retry_stops_on_non_retryable_error() {
331        let retry_config = RetryConfig::default()
332            .with_max_retries(3)
333            .with_initial_delay(Duration::ZERO)
334            .with_max_delay(Duration::ZERO);
335        let attempts = Arc::new(AtomicU32::new(0));
336
337        let error = execute_with_retry(&retry_config, is_retryable_model_error, || {
338            let attempts = Arc::clone(&attempts);
339            async move {
340                attempts.fetch_add(1, Ordering::SeqCst);
341                Err::<(), _>(AdkError::model("HTTP 400 bad request"))
342            }
343        })
344        .await
345        .expect_err("operation should fail without retries");
346
347        assert!(error.is_model());
348        assert_eq!(attempts.load(Ordering::SeqCst), 1);
349    }
350
351    #[tokio::test]
352    async fn execute_with_retry_respects_disabled_config() {
353        let retry_config = RetryConfig::disabled().with_max_retries(10);
354        let attempts = Arc::new(AtomicU32::new(0));
355
356        let error = execute_with_retry(&retry_config, is_retryable_model_error, || {
357            let attempts = Arc::clone(&attempts);
358            async move {
359                attempts.fetch_add(1, Ordering::SeqCst);
360                Err::<(), _>(AdkError::model("HTTP 429 too many requests"))
361            }
362        })
363        .await
364        .expect_err("disabled retries should return first error");
365
366        assert!(error.is_model());
367        assert_eq!(attempts.load(Ordering::SeqCst), 1);
368    }
369
370    #[test]
371    fn retryable_status_code_matches_transient_errors() {
372        assert!(is_retryable_status_code(429));
373        assert!(is_retryable_status_code(503));
374        assert!(is_retryable_status_code(529));
375        assert!(!is_retryable_status_code(400));
376        assert!(!is_retryable_status_code(401));
377    }
378
379    #[tokio::test]
380    async fn server_retry_after_is_capped_at_max_delay() {
381        let retry_config = RetryConfig::default()
382            .with_max_retries(1)
383            .with_initial_delay(Duration::ZERO)
384            .with_max_delay(Duration::from_millis(20));
385        let attempts = Arc::new(AtomicU32::new(0));
386        let started = std::time::Instant::now();
387
388        let result = execute_with_retry(&retry_config, is_retryable_model_error, || {
389            let attempts = Arc::clone(&attempts);
390            async move {
391                if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
392                    let error = AdkError::new(
393                        adk_core::ErrorComponent::Model,
394                        ErrorCategory::Unavailable,
395                        "model.test.unavailable",
396                        "overloaded",
397                    );
398                    return Err(with_retry_after(error, Some(Duration::from_secs(3600))));
399                }
400                Ok("ok")
401            }
402        })
403        .await;
404
405        assert_eq!(result.unwrap(), "ok");
406        assert!(started.elapsed() < Duration::from_secs(5), "delay was not capped");
407    }
408
409    #[tokio::test]
410    async fn server_hint_is_capped_at_max_delay() {
411        let retry_config = RetryConfig::default()
412            .with_max_retries(1)
413            .with_initial_delay(Duration::ZERO)
414            .with_max_delay(Duration::from_millis(20));
415        let hint = ServerRetryHint { retry_after: Some(Duration::from_secs(3600)) };
416        let attempts = Arc::new(AtomicU32::new(0));
417        let started = std::time::Instant::now();
418
419        let result = execute_with_retry_hint(
420            &retry_config,
421            is_retryable_model_error,
422            Some(&hint),
423            &mut || {
424                let attempts = Arc::clone(&attempts);
425                async move {
426                    if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
427                        return Err(AdkError::model("HTTP 503 unavailable"));
428                    }
429                    Ok("ok")
430                }
431            },
432        )
433        .await;
434
435        assert_eq!(result.unwrap(), "ok");
436        assert!(started.elapsed() < Duration::from_secs(5), "delay was not capped");
437    }
438
439    #[test]
440    fn transient_server_statuses_map_to_retryable_unavailable() {
441        for status in [500, 502, 503, 504, 529] {
442            assert_eq!(category_for_status_code(status), ErrorCategory::Unavailable, "{status}");
443        }
444        assert_eq!(category_for_status_code(501), ErrorCategory::Internal);
445        assert_eq!(category_for_status_code(429), ErrorCategory::RateLimited);
446        assert_eq!(category_for_status_code(408), ErrorCategory::Timeout);
447        assert_eq!(category_for_status_code(422), ErrorCategory::InvalidInput);
448    }
449
450    #[test]
451    fn retry_after_is_attached_only_to_retryable_errors() {
452        let delay = Some(Duration::from_secs(4));
453        let unavailable = AdkError::new(
454            adk_core::ErrorComponent::Model,
455            ErrorCategory::Unavailable,
456            "model.test",
457            "down",
458        );
459        let invalid = AdkError::new(
460            adk_core::ErrorComponent::Model,
461            ErrorCategory::InvalidInput,
462            "model.test",
463            "bad",
464        );
465
466        assert_eq!(
467            with_retry_after(unavailable, delay).retry.retry_after(),
468            Some(Duration::from_secs(4))
469        );
470        assert_eq!(with_retry_after(invalid, delay).retry.retry_after(), None);
471        assert_eq!(parse_retry_after("-1"), None);
472        assert_eq!(parse_retry_after(" 30 "), Some(Duration::from_secs(30)));
473    }
474
475    #[test]
476    fn retryable_error_message_matches_529_and_overloaded() {
477        assert!(is_retryable_error_message("HTTP 529 overloaded"));
478        assert!(is_retryable_error_message("Server OVERLOADED, try again"));
479    }
480
481    #[tokio::test]
482    async fn execute_with_retry_hint_uses_server_delay() {
483        let retry_config = RetryConfig::default()
484            .with_max_retries(2)
485            .with_initial_delay(Duration::ZERO)
486            .with_max_delay(Duration::ZERO);
487        let attempts = Arc::new(AtomicU32::new(0));
488        let hint = ServerRetryHint { retry_after: Some(Duration::ZERO) };
489
490        let result = execute_with_retry_hint(
491            &retry_config,
492            is_retryable_model_error,
493            Some(&hint),
494            &mut || {
495                let attempts = Arc::clone(&attempts);
496                async move {
497                    let attempt = attempts.fetch_add(1, Ordering::SeqCst);
498                    if attempt < 1 {
499                        return Err(AdkError::model("HTTP 429 rate limit"));
500                    }
501                    Ok("ok")
502                }
503            },
504        )
505        .await
506        .expect("operation should succeed after retry with hint");
507
508        assert_eq!(result, "ok");
509        assert_eq!(attempts.load(Ordering::SeqCst), 2);
510    }
511
512    /// Requirement 5.2: HTTP 529 (overloaded) is retried end-to-end.
513    #[tokio::test]
514    async fn status_529_is_retried_end_to_end() {
515        let retry_config = RetryConfig::default()
516            .with_max_retries(2)
517            .with_initial_delay(Duration::ZERO)
518            .with_max_delay(Duration::ZERO);
519        let attempts = Arc::new(AtomicU32::new(0));
520
521        let result = execute_with_retry(&retry_config, is_retryable_model_error, || {
522            let attempts = Arc::clone(&attempts);
523            async move {
524                let attempt = attempts.fetch_add(1, Ordering::SeqCst);
525                if attempt == 0 {
526                    return Err(AdkError::model("HTTP 529 overloaded"));
527                }
528                Ok("recovered")
529            }
530        })
531        .await
532        .expect("529 should be retried and succeed on second attempt");
533
534        assert_eq!(result, "recovered");
535        assert_eq!(attempts.load(Ordering::SeqCst), 2);
536    }
537
538    /// Requirement 5.4: Exponential backoff when retry-after is absent.
539    /// With initial_delay=20ms and multiplier=2.0, the delays should be
540    /// ~20ms (attempt 1) then ~40ms (attempt 2). We verify each gap is
541    /// at least the expected delay.
542    #[tokio::test]
543    async fn exponential_backoff_without_retry_after() {
544        let retry_config = RetryConfig::default()
545            .with_max_retries(3)
546            .with_initial_delay(Duration::from_millis(20))
547            .with_max_delay(Duration::from_millis(200))
548            .with_backoff_multiplier(2.0);
549
550        let timestamps: Arc<std::sync::Mutex<Vec<std::time::Instant>>> =
551            Arc::new(std::sync::Mutex::new(Vec::new()));
552
553        let result = execute_with_retry(&retry_config, is_retryable_model_error, || {
554            let timestamps = Arc::clone(&timestamps);
555            async move {
556                let now = std::time::Instant::now();
557                let mut ts = timestamps.lock().unwrap();
558                let attempt = ts.len();
559                ts.push(now);
560                if attempt < 3 {
561                    return Err(AdkError::model("HTTP 429 rate limit"));
562                }
563                Ok("done")
564            }
565        })
566        .await
567        .expect("should succeed after backoff retries");
568
569        assert_eq!(result, "done");
570
571        let ts = timestamps.lock().unwrap();
572        assert_eq!(ts.len(), 4); // initial + 3 retries
573
574        // Gap between attempt 0 and 1 should be >= initial_delay (20ms).
575        let gap1 = ts[1].duration_since(ts[0]);
576        assert!(gap1 >= Duration::from_millis(18), "first backoff gap {gap1:?} should be >= ~20ms");
577
578        // Gap between attempt 1 and 2 should be >= 2 * initial_delay (40ms).
579        let gap2 = ts[2].duration_since(ts[1]);
580        assert!(
581            gap2 >= Duration::from_millis(36),
582            "second backoff gap {gap2:?} should be >= ~40ms"
583        );
584
585        // Gap 2 should be roughly double gap 1 (with tolerance for scheduling).
586        assert!(gap2 >= gap1, "backoff should increase: gap2={gap2:?} should be >= gap1={gap1:?}");
587    }
588}