Skip to main content

tripo_api/
retry.rs

1//! Retry policy with exponential backoff + full jitter.
2
3use std::time::Duration;
4
5use reqwest::StatusCode;
6
7/// Controls retry behavior.
8#[derive(Debug, Clone)]
9pub struct RetryPolicy {
10    /// Maximum retry attempts beyond the initial request (`max_attempts = 3` → up to 4 total sends).
11    pub max_attempts: u32,
12    /// Base delay before the first retry.
13    pub base_delay: Duration,
14    /// Maximum delay between retries.
15    pub max_delay: Duration,
16}
17
18impl Default for RetryPolicy {
19    fn default() -> Self {
20        Self {
21            max_attempts: 3,
22            base_delay: Duration::from_secs(1),
23            max_delay: Duration::from_secs(30),
24        }
25    }
26}
27
28/// Outcome of inspecting one response for retry eligibility.
29#[derive(Debug, Clone, Copy)]
30pub(crate) enum RetryDecision {
31    /// Retry after this delay.
32    Retry(Duration),
33    /// Do not retry — terminal.
34    Stop,
35}
36
37impl RetryPolicy {
38    /// Decide whether a status code is retryable.
39    pub(crate) fn decide_status(
40        &self,
41        attempt: u32,
42        status: StatusCode,
43        retry_after: Option<Duration>,
44    ) -> RetryDecision {
45        if attempt >= self.max_attempts {
46            return RetryDecision::Stop;
47        }
48        match status.as_u16() {
49            429 => RetryDecision::Retry(retry_after.unwrap_or_else(|| self.backoff(attempt))),
50            500..=599 => RetryDecision::Retry(self.backoff(attempt)),
51            _ => RetryDecision::Stop,
52        }
53    }
54
55    /// Decide whether a transport error is retryable.
56    ///
57    /// Only connect-time and timeout errors are retried — both are safe because
58    /// either no bytes reached the server, or the client didn't observe the
59    /// server's state change. Post-connect errors (partial body send, broken
60    /// streams) are not retried to avoid duplicate side-effects on non-idempotent
61    /// endpoints like `POST /task`.
62    pub(crate) fn decide_transport(&self, attempt: u32, err: &reqwest::Error) -> RetryDecision {
63        if attempt >= self.max_attempts {
64            return RetryDecision::Stop;
65        }
66        // Retry only on connect/timeout — these are safe because no bytes reached the server
67        // (or if they did, the server's state change hasn't been observed). Post-connect errors
68        // (e.g. partial body send) might have side effects, so we stop.
69        if err.is_connect() || err.is_timeout() {
70            RetryDecision::Retry(self.backoff(attempt))
71        } else {
72            RetryDecision::Stop
73        }
74    }
75
76    /// Exponential backoff with full jitter.
77    fn backoff(&self, attempt: u32) -> Duration {
78        let exp = 2u64.saturating_pow(attempt);
79        let factor = u32::try_from(exp).unwrap_or(u32::MAX);
80        let max = (self.base_delay * factor).min(self.max_delay);
81        // Full jitter: sample uniformly in [0, max].
82        let nanos = u64::try_from(max.as_nanos()).unwrap_or(u64::MAX);
83        let jitter = rand_nanos(nanos);
84        Duration::from_nanos(jitter)
85    }
86}
87
88/// Deterministic-enough jitter source that avoids pulling in `rand`.
89fn rand_nanos(max: u64) -> u64 {
90    use std::time::SystemTime;
91    if max == 0 {
92        return 0;
93    }
94    let n = SystemTime::now()
95        .duration_since(SystemTime::UNIX_EPOCH)
96        .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
97    n % max
98}
99
100/// Parse `Retry-After` header value (either seconds or HTTP date — seconds only for now).
101pub(crate) fn parse_retry_after(v: &reqwest::header::HeaderValue) -> Option<Duration> {
102    let s = v.to_str().ok()?;
103    s.trim().parse::<u64>().ok().map(Duration::from_secs)
104}