1use adk_core::{AdkError, ErrorCategory, Result};
2use std::{future::Future, time::Duration};
3
4#[derive(Clone, Debug)]
6pub struct RetryConfig {
7 pub enabled: bool,
9 pub max_retries: u32,
11 pub initial_delay: Duration,
13 pub max_delay: Duration,
17 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 #[must_use]
36 pub fn disabled() -> Self {
37 Self { enabled: false, ..Self::default() }
38 }
39
40 #[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 #[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 #[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 #[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#[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#[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#[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#[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#[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#[must_use]
176pub fn is_retryable_model_error(error: &AdkError) -> bool {
177 if error.retry.should_retry {
179 return true;
180 }
181 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#[derive(Debug, Clone, Default)]
214pub struct ServerRetryHint {
215 pub retry_after: Option<Duration>,
217}
218
219pub 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
233pub 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 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 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 #[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 #[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(×tamps);
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); let gap1 = ts[1].duration_since(ts[0]);
576 assert!(gap1 >= Duration::from_millis(18), "first backoff gap {gap1:?} should be >= ~20ms");
577
578 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 assert!(gap2 >= gap1, "backoff should increase: gap2={gap2:?} should be >= gap1={gap1:?}");
587 }
588}