retry.rsannotatedretry.rssource125 lines · 4.9 KB · raw
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}