Skip to main content

openkind_client/retry/
policy.rs

1use std::time::Duration;
2
3use crate::error::Error;
4
5/// Default statuses retried in addition to all 5xx: request timeout and rate
6/// limit (529 is covered by the 5xx rule).
7pub const DEFAULT_RETRY_STATUSES: [u16; 2] = [408, 429];
8
9/// Extra retry opt-in mirroring the Python SDK's `predicate` / `exceptions`
10/// options: called with every error; returning `true` retries it even when
11/// the built-in rules would not.
12pub type RetryPredicate = std::sync::Arc<dyn Fn(&Error) -> bool + Send + Sync>;
13
14/// Configuration for client retry behavior.
15///
16/// # Examples
17/// ```
18/// use std::time::Duration;
19/// use openkind_client::RetryPolicy;
20///
21/// // Three quick retries, honoring the server's Retry-After.
22/// let policy = RetryPolicy::new()
23///     .max_retries(3)
24///     .backoff_initial(Duration::from_millis(100))
25///     .backoff_max(Duration::from_secs(2));
26/// ```
27#[derive(Clone)]
28pub struct RetryPolicy {
29    pub(crate) max_retries: u32,
30    pub(crate) backoff_initial: Duration,
31    pub(crate) backoff_max: Duration,
32    pub(crate) backoff_jitter: f64,
33    pub(crate) retry_statuses: Vec<u16>,
34    pub(crate) retry_server_errors: bool,
35    pub(crate) respect_retry_after: bool,
36    pub(crate) retry_connection_errors: bool,
37    pub(crate) retry_timeout_errors: bool,
38    pub(crate) total_timeout: Option<Duration>,
39    pub(crate) retry_predicate: Option<RetryPredicate>,
40}
41
42// Manual impl: the predicate closure is not `Debug` (it may capture secrets);
43// everything else is.
44impl std::fmt::Debug for RetryPolicy {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("RetryPolicy")
47            .field("max_retries", &self.max_retries)
48            .field("backoff_initial", &self.backoff_initial)
49            .field("backoff_max", &self.backoff_max)
50            .field("backoff_jitter", &self.backoff_jitter)
51            .field("retry_statuses", &self.retry_statuses)
52            .field("retry_server_errors", &self.retry_server_errors)
53            .field("respect_retry_after", &self.respect_retry_after)
54            .field("retry_connection_errors", &self.retry_connection_errors)
55            .field("retry_timeout_errors", &self.retry_timeout_errors)
56            .field("total_timeout", &self.total_timeout)
57            .field("retry_predicate", &self.retry_predicate.is_some())
58            .finish()
59    }
60}
61
62impl Default for RetryPolicy {
63    fn default() -> Self {
64        Self {
65            max_retries: 2,
66            backoff_initial: Duration::from_millis(500),
67            backoff_max: Duration::from_secs(5),
68            backoff_jitter: 0.25,
69            retry_statuses: DEFAULT_RETRY_STATUSES.to_vec(),
70            retry_server_errors: true,
71            respect_retry_after: true,
72            retry_connection_errors: true,
73            retry_timeout_errors: true,
74            total_timeout: Some(Duration::from_secs(30)),
75            retry_predicate: None,
76        }
77    }
78}
79
80impl RetryPolicy {
81    /// A policy with the SDK-default settings (same as [`RetryPolicy::default`]).
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Maximum retries after the initial attempt; `0` disables retries.
87    pub fn max_retries(mut self, max_retries: u32) -> Self {
88        self.max_retries = max_retries;
89        self
90    }
91
92    /// First backoff delay, doubled each attempt up to [`backoff_max`](Self::backoff_max).
93    /// Zero disables backoff delays entirely.
94    pub fn backoff_initial(mut self, initial: Duration) -> Self {
95        self.backoff_initial = initial;
96        self
97    }
98
99    /// Upper bound on exponential backoff delays. Zero disables backoff delays entirely.
100    pub fn backoff_max(mut self, max: Duration) -> Self {
101        self.backoff_max = max;
102        self
103    }
104
105    /// Fraction of each backoff delay randomly subtracted, in `0.0..=1.0`.
106    /// `0.0` makes delays fully deterministic (useful in tests).
107    pub fn backoff_jitter(mut self, jitter: f64) -> Self {
108        self.backoff_jitter = jitter;
109        self
110    }
111
112    /// Retry these status codes (in addition to 5xx while
113    /// [`retry_server_errors`](Self::retry_server_errors) is set). The
114    /// default retries 408 and 429.
115    pub fn retry_status(mut self, status: u16) -> Self {
116        if !self.retry_statuses.contains(&status) {
117            self.retry_statuses.push(status);
118        }
119        self
120    }
121
122    /// Replace the retried-status list wholesale, mirroring the Python SDK's
123    /// `http_statuses=[...]` semantics: only these codes (plus 5xx while
124    /// [`retry_server_errors`](Self::retry_server_errors) is set) are retried.
125    ///
126    /// `RetryPolicy::new().with_retry_statuses([409]).retry_server_errors(false)`
127    /// retries 409 and nothing else.
128    pub fn with_retry_statuses(mut self, statuses: impl IntoIterator<Item = u16>) -> Self {
129        self.retry_statuses = statuses.into_iter().collect();
130        self
131    }
132
133    /// Opt extra errors into retrying, mirroring the Python SDK's
134    /// `predicate`/`exceptions` options: `Some(predicate)` retries any error
135    /// for which the predicate returns `true`, even non-transient statuses
136    /// like 404. `None` (the default) uses only the built-in rules.
137    pub fn retry_predicate(mut self, predicate: Option<RetryPredicate>) -> Self {
138        self.retry_predicate = predicate;
139        self
140    }
141
142    /// Whether any 5xx status (including 529 Overloaded) is retried. Default `true`.
143    pub fn retry_server_errors(mut self, retry: bool) -> Self {
144        self.retry_server_errors = retry;
145        self
146    }
147
148    /// Whether to honor `Retry-After` / `retry-after-ms` response headers
149    /// instead of the computed backoff. Default `true`.
150    pub fn respect_retry_after(mut self, respect: bool) -> Self {
151        self.respect_retry_after = respect;
152        self
153    }
154
155    /// Whether connection failures are retried. Default `true`.
156    pub fn retry_connection_errors(mut self, retry: bool) -> Self {
157        self.retry_connection_errors = retry;
158        self
159    }
160
161    /// Whether per-attempt timeouts are retried. Default `true`.
162    pub fn retry_timeout_errors(mut self, retry: bool) -> Self {
163        self.retry_timeout_errors = retry;
164        self
165    }
166
167    /// Total budget per call, including the initial attempt and all delays.
168    /// A retry whose delay would reach or exceed the remaining budget is not
169    /// attempted; the last error is returned instead. `None` disables the limit.
170    pub fn total_timeout(mut self, timeout: Option<Duration>) -> Self {
171        self.total_timeout = timeout;
172        self
173    }
174
175    /// Validate jitter bounds. Called when the client is built so misconfig
176    /// surfaces at construction, not mid-call.
177    pub(crate) fn validate(&self) -> Result<(), Error> {
178        if !(0.0..=1.0).contains(&self.backoff_jitter) || !self.backoff_jitter.is_finite() {
179            return Err(Error::Config(format!(
180                "backoff_jitter must be between 0.0 and 1.0, got {}",
181                self.backoff_jitter
182            )));
183        }
184        Ok(())
185    }
186
187    /// Whether this error should be retried under the policy.
188    pub fn is_retryable(&self, error: &Error) -> bool {
189        let builtin = match error {
190            Error::Connection(_) => self.retry_connection_errors,
191            Error::Timeout { .. } => self.retry_timeout_errors,
192            Error::Api(api) => {
193                self.retry_server_errors && (500..600).contains(&api.status)
194                    || self.retry_statuses.contains(&api.status)
195            }
196            // Invalid responses and local failures are not transient.
197            Error::Config(_)
198            | Error::Decode { .. }
199            | Error::InvalidResponse { .. }
200            | Error::ResponseTooLarge { .. } => false,
201        };
202        builtin || self.retry_predicate.as_ref().is_some_and(|f| f(error))
203    }
204
205    /// How long to wait before the retry numbered `retry_index`
206    /// (0-based: `0` is the wait before the first retry).
207    ///
208    /// A server-requested `Retry-After` takes precedence over computed
209    /// backoff when [`respect_retry_after`](Self::respect_retry_after) is set.
210    pub fn delay_for(&self, error: &Error, retry_index: u32) -> Duration {
211        if self.respect_retry_after {
212            if let Some(delay) = error.retry_after() {
213                return delay;
214            }
215        }
216        self.backoff_delay(retry_index)
217    }
218
219    /// Exponential backoff with subtractive jitter:
220    /// `min(backoff_max, backoff_initial * 2^retry_index) * (1 - U * jitter)`.
221    ///
222    /// Nanosecond precision throughout, so sub-millisecond initial delays
223    /// and very large caps behave like the Python SDK's float math.
224    pub fn backoff_delay(&self, retry_index: u32) -> Duration {
225        if self.backoff_initial.is_zero() || self.backoff_max.is_zero() {
226            return Duration::ZERO;
227        }
228        let initial_ns = u64::try_from(self.backoff_initial.as_nanos()).unwrap_or(u64::MAX);
229        let multiplier = 1u64.checked_shl(retry_index).unwrap_or(u64::MAX);
230        let exponential = initial_ns.saturating_mul(multiplier);
231        let maximum_ns = u64::try_from(self.backoff_max.as_nanos()).unwrap_or(u64::MAX);
232        let capped = exponential.min(maximum_ns);
233        let jitter_ns = (capped as f64 * self.backoff_jitter * fastrand::f64()) as u64;
234        Duration::from_nanos(capped.saturating_sub(jitter_ns))
235    }
236}