Skip to main content

leyline/core/
retry.rs

1use std::time::Duration;
2
3use crate::util::epoch_plus;
4
5pub(crate) const GATEWAY_STATUSES: [u16; 3] = [502, 503, 504];
6
7#[derive(Debug, Clone, Copy)]
8#[non_exhaustive]
9pub enum RetryTrigger {
10    ConnectionError,
11    Status(u16),
12    ServerError,
13    Timeout,
14}
15
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct RetryPolicy {
19    pub(crate) max_retries: u32,
20    pub(crate) initial_backoff: Duration,
21    pub(crate) max_backoff: Duration,
22    pub(crate) max_retry_after: Duration,
23    pub(crate) backoff_factor: f64,
24    pub(crate) jitter: bool,
25    pub(crate) retry_on: Vec<RetryTrigger>,
26    pub(crate) allow_non_idempotent: bool,
27}
28
29impl Default for RetryPolicy {
30    fn default() -> Self {
31        Self::none()
32    }
33}
34
35impl RetryPolicy {
36    pub fn none() -> Self {
37        Self {
38            max_retries: 0,
39            initial_backoff: Duration::from_millis(0),
40            max_backoff: Duration::from_millis(0),
41            max_retry_after: Duration::MAX,
42            backoff_factor: 1.0,
43            jitter: false,
44            retry_on: Vec::new(),
45            allow_non_idempotent: false,
46        }
47    }
48
49    pub fn transient() -> Self {
50        Self {
51            max_retries: 3,
52            initial_backoff: Duration::from_millis(100),
53            max_backoff: Duration::from_secs(1),
54            max_retry_after: Duration::MAX,
55            backoff_factor: 2.0,
56            jitter: true,
57            retry_on: [RetryTrigger::ConnectionError, RetryTrigger::Status(429)]
58                .into_iter()
59                .chain(GATEWAY_STATUSES.map(RetryTrigger::Status))
60                .chain([RetryTrigger::Timeout])
61                .collect(),
62            allow_non_idempotent: false,
63        }
64    }
65
66    pub fn max_retries(mut self, n: u32) -> Self {
67        self.max_retries = n;
68        self
69    }
70
71    pub fn initial_backoff(mut self, d: Duration) -> Self {
72        self.initial_backoff = d;
73        self
74    }
75
76    pub fn max_backoff(mut self, d: Duration) -> Self {
77        self.max_backoff = d;
78        self
79    }
80
81    pub fn backoff_factor(mut self, factor: f64) -> Self {
82        self.backoff_factor = factor;
83        self
84    }
85
86    pub fn jitter(mut self, on: bool) -> Self {
87        self.jitter = on;
88        self
89    }
90
91    pub fn max_retry_after(mut self, d: Duration) -> Self {
92        self.max_retry_after = d;
93        self
94    }
95
96    pub fn on_status(mut self, code: u16) -> Self {
97        self.retry_on.push(RetryTrigger::Status(code));
98        self
99    }
100
101    pub fn retry_on(mut self, triggers: impl IntoIterator<Item = RetryTrigger>) -> Self {
102        self.retry_on = triggers.into_iter().collect();
103        self
104    }
105
106    pub fn allow_non_idempotent(mut self, allow: bool) -> Self {
107        self.allow_non_idempotent = allow;
108        self
109    }
110
111    pub(crate) fn is_none(&self) -> bool {
112        self.max_retries == 0
113    }
114
115    pub(crate) fn matches_status(&self, status: u16) -> bool {
116        for t in &self.retry_on {
117            match *t {
118                RetryTrigger::Status(s) if s == status => return true,
119                RetryTrigger::ServerError if (500..600).contains(&status) => return true,
120                _ => {}
121            }
122        }
123        false
124    }
125
126    pub(crate) fn matches_connection_error(&self) -> bool {
127        self.retry_on
128            .iter()
129            .any(|t| matches!(t, RetryTrigger::ConnectionError))
130    }
131
132    pub(crate) fn matches_timeout(&self) -> bool {
133        self.retry_on
134            .iter()
135            .any(|t| matches!(t, RetryTrigger::Timeout))
136    }
137
138    pub(crate) fn delay(&self, attempt: u32) -> Duration {
139        let base = self.initial_backoff.as_secs_f64();
140        let raw = base * self.backoff_factor.powi(attempt as i32);
141        let capped = raw.min(self.max_backoff.as_secs_f64());
142        let jittered = if self.jitter {
143            capped * cheap_jitter()
144        } else {
145            capped
146        };
147        Duration::from_secs_f64(jittered.max(0.0))
148    }
149}
150
151fn cheap_jitter() -> f64 {
152    use rand::Rng;
153    rand::rng().random_range(0.0..=1.0)
154}
155
156pub(crate) fn parse_retry_after(value: &str) -> Option<Duration> {
157    let value = value.trim();
158    if let Ok(secs) = value.parse::<u64>() {
159        return Some(Duration::from_secs(secs));
160    }
161    let when = parse_imf_fixdate(value)?;
162    Some(
163        when.duration_since(std::time::SystemTime::now())
164            .unwrap_or(Duration::ZERO),
165    )
166}
167
168fn parse_imf_fixdate(s: &str) -> Option<std::time::SystemTime> {
169    let rest = s.split_once(", ")?.1;
170    let mut parts = rest.split_whitespace();
171    let day: u32 = parts.next()?.parse().ok()?;
172    let month = month_num(parts.next()?)?;
173    let year: i32 = parts.next()?.parse().ok()?;
174    let hms = parts.next()?;
175    let tz = parts.next()?;
176    if !tz.eq_ignore_ascii_case("GMT") && !tz.eq_ignore_ascii_case("UTC") {
177        return None;
178    }
179    let mut t = hms.split(':');
180    let hour: u32 = t.next()?.parse().ok()?;
181    let min: u32 = t.next()?.parse().ok()?;
182    let sec: u32 = t.next()?.parse().ok()?;
183    unix_from_ymd_hms(year, month, day, hour, min, sec)
184}
185
186fn month_num(month: &str) -> Option<u32> {
187    Some(match month {
188        "Jan" => 1,
189        "Feb" => 2,
190        "Mar" => 3,
191        "Apr" => 4,
192        "May" => 5,
193        "Jun" => 6,
194        "Jul" => 7,
195        "Aug" => 8,
196        "Sep" => 9,
197        "Oct" => 10,
198        "Nov" => 11,
199        "Dec" => 12,
200        _ => return None,
201    })
202}
203
204fn unix_from_ymd_hms(
205    year: i32,
206    month: u32,
207    day: u32,
208    hour: u32,
209    min: u32,
210    sec: u32,
211) -> Option<std::time::SystemTime> {
212    if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
213        return None;
214    }
215    let mut y = year;
216    if month <= 2 {
217        y -= 1;
218    }
219    let era = y.div_euclid(400);
220    let yoe = (y - era * 400) as u32;
221    let shifted = if month > 2 { month - 3 } else { month + 9 };
222    let doy = (153 * shifted + 2) / 5 + day - 1;
223    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
224    let days = i64::from(era) * 146097 + i64::from(doe) - 719468;
225    let secs = days
226        .checked_mul(86400)?
227        .checked_add(i64::from(hour) * 3600 + i64::from(min) * 60 + i64::from(sec))?;
228    if secs < 0 {
229        return None;
230    }
231    epoch_plus(Duration::from_secs(secs as u64))
232}
233
234#[cfg(test)]
235mod tests;