The retry policy, as the vendor SDKs implement it (Python
typesafe-sdk 0.7.1 _core/retry.py, JS @typesafe-ai/sdk 0.6.0,
read 2026-09-22; contract Execution).
5use std::time::{Duration, SystemTime};
Retries after the first attempt.
8pub const MAX_RETRIES: u32 = 2;
Each attempt's own limit.
10pub const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10);
All attempts and waits together.
12pub const BUDGET: Duration = Duration::from_secs(30);
A server-requested delay longer than this is clamped (the JS SDK's cap).
17const SERVER_DELAY_CAP: Duration = Duration::from_secs(60);
How an attempt ended, as far as the policy cares.
20pub enum Outcome<'a> {
A response: its status and headers (the delay headers are read here, so their names live in this crate only).
23 Status { status: u16, headers: &'a http::HeaderMap },
Whether to retry after attempt number retries_so_far + 1, and how long
to wait first. jitter is uniform in [0, 1); now resolves an
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}
408, 429 and every 5xx (529 included).
0.5 s doubling to a 5 s cap, less up to 25% jitter.
retry-after-ms first, then Retry-After as seconds or an
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}
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}