1//! The retry policy, as the vendor SDKs implement it (Python 2//! `typesafe-sdk` 0.7.1 `_core/retry.py`, JS `@typesafe-ai/sdk` 0.6.0, 3//! read 2026-09-22; contract *Execution*). 4 5use std::time::{Duration, SystemTime}; 6 7/// Retries after the first attempt. 8pub const MAX_RETRIES: u32 = 2; 9/// Each attempt's own limit. 10pub const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); 11/// All attempts and waits together. 12pub const BUDGET: Duration = Duration::from_secs(30); 13 14const BACKOFF_START: Duration = Duration::from_millis(500); 15const BACKOFF_CAP: Duration = Duration::from_secs(5); 16/// A server-requested delay longer than this is clamped (the JS SDK's cap). 17const SERVER_DELAY_CAP: Duration = Duration::from_secs(60); 18 19/// How an attempt ended, as far as the policy cares. 20pub enum Outcome<'a> { 21 /// A response: its status and headers (the delay headers are read 22 /// here, so their names live in this crate only). 23 Status { status: u16, headers: &'a http::HeaderMap }, 24 /// No response: connection failure or the attempt timed out. 25 Transport, 26} 27 28/// Whether to retry after attempt number `retries_so_far + 1`, and how long 29/// to wait first. `jitter` is uniform in [0, 1); `now` resolves an 30/// HTTP-date `Retry-After`. 31pub fn next_delay(outcome: &Outcome, retries_so_far: u32, jitter: f64, now: SystemTime) -> Option<Duration> { 32 if retries_so_far >= MAX_RETRIES { 33 return None; 34 } 35 match *outcome { 36 Outcome::Transport => Some(backoff(retries_so_far, jitter)), 37 Outcome::Status { status, headers } if retryable(status) => { 38 let header = |name| headers.get(name).and_then(|v| v.to_str().ok()); 39 Some( 40 server_delay(header("retry-after-ms"), header("retry-after"), now) 41 .unwrap_or_else(|| backoff(retries_so_far, jitter)), 42 ) 43 } 44 Outcome::Status { .. } => None, 45 } 46} 47 48/// 408, 429 and every 5xx (529 included). 49pub fn retryable(status: u16) -> bool { 50 status == 408 || status == 429 || (500..=599).contains(&status) 51} 52 53/// 0.5 s doubling to a 5 s cap, less up to 25% jitter. 54fn backoff(retries_so_far: u32, jitter: f64) -> Duration { 55 let base = BACKOFF_START.saturating_mul(1 << retries_so_far.min(16)).min(BACKOFF_CAP); 56 base.mul_f64(1.0 - 0.25 * jitter.clamp(0.0, 1.0)) 57} 58 59/// `retry-after-ms` first, then `Retry-After` as seconds or an 60/// HTTP-date, capped at 60 s. 61fn server_delay(retry_after_ms: Option<&str>, retry_after: Option<&str>, now: SystemTime) -> Option<Duration> { 62 let from_ms = retry_after_ms 63 .and_then(|v| v.trim().parse::<f64>().ok()) 64 .filter(|ms| ms.is_finite() && *ms >= 0.0) 65 .map(|ms| Duration::from_secs_f64(ms / 1000.0)); 66 let from_header = || { 67 let v = retry_after?.trim(); 68 match v.parse::<f64>() { 69 Ok(s) if s.is_finite() && s >= 0.0 => Some(Duration::from_secs_f64(s)), 70 Ok(_) => None, 71 Err(_) => httpdate::parse_http_date(v) 72 .ok() 73 .map(|at| at.duration_since(now).unwrap_or(Duration::ZERO)), 74 } 75 }; 76 from_ms.or_else(from_header).map(|d| d.min(SERVER_DELAY_CAP)) 77} 78 79#[cfg(test)] 80mod tests { 81 use super::*; 82 83 fn status(status: u16) -> Outcome<'static> { 84 static EMPTY: std::sync::LazyLock<http::HeaderMap> = std::sync::LazyLock::new(http::HeaderMap::new); 85 Outcome::Status { status, headers: &EMPTY } 86 } 87 88 #[test] 89 fn retries_only_retryable_statuses() { 90 let now = SystemTime::UNIX_EPOCH; 91 for s in [408, 429, 500, 503, 529, 599] { 92 assert!(next_delay(&status(s), 0, 0.0, now).is_some(), "{s}"); 93 } 94 for s in [200, 400, 401, 403, 404, 422] { 95 assert!(next_delay(&status(s), 0, 0.0, now).is_none(), "{s}"); 96 } 97 assert!(next_delay(&Outcome::Transport, 0, 0.0, now).is_some()); 98 } 99 100 #[test] 101 fn at_most_two_retries() { 102 let now = SystemTime::UNIX_EPOCH; 103 assert!(next_delay(&status(503), 1, 0.0, now).is_some()); 104 assert!(next_delay(&status(503), 2, 0.0, now).is_none()); 105 } 106 107 #[test] 108 fn backoff_doubles_to_a_cap_less_jitter() { 109 assert_eq!(backoff(0, 0.0), Duration::from_millis(500)); 110 assert_eq!(backoff(1, 0.0), Duration::from_secs(1)); 111 assert_eq!(backoff(10, 0.0), Duration::from_secs(5)); 112 assert_eq!(backoff(0, 1.0), Duration::from_millis(375)); 113 } 114 115 #[test] 116 fn server_delay_prefers_ms_then_seconds_then_date() { 117 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000); 118 let d = |ms, s| server_delay(ms, s, now); 119 assert_eq!(d(Some("300"), Some("9")), Some(Duration::from_millis(300))); 120 assert_eq!(d(None, Some("2")), Some(Duration::from_secs(2))); 121 assert_eq!(d(None, Some("Sun, 09 Sep 2001 01:46:50 GMT")), Some(Duration::from_secs(10))); 122 assert_eq!(d(None, Some("999")), Some(Duration::from_secs(60))); 123 assert_eq!(d(Some("junk"), None), None); 124 } 125}