Skip to main content

braid_http/client/
retry.rs

1//! Retry configuration and logic for Braid HTTP client.
2
3use std::time::Duration;
4
5/// Configuration for retry behavior.
6#[derive(Debug, Clone)]
7pub struct RetryConfig {
8    /// Maximum number of retry attempts (None = infinite)
9    pub max_retries: Option<u32>,
10    /// Initial backoff duration
11    pub initial_backoff: Duration,
12    /// Maximum backoff duration
13    pub max_backoff: Duration,
14    /// HTTP status codes that trigger a retry
15    pub retry_on_status: Vec<u16>,
16    /// Whether to respect the `Retry-After` header
17    pub respect_retry_after: bool,
18}
19
20impl Default for RetryConfig {
21    fn default() -> Self {
22        Self {
23            max_retries: None,
24            initial_backoff: Duration::from_secs(1),
25            max_backoff: Duration::from_secs(3),
26            retry_on_status: vec![408, 425, 429, 502, 503, 504],
27            respect_retry_after: true,
28        }
29    }
30}
31
32impl RetryConfig {
33    #[must_use]
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    #[must_use]
39    pub fn no_retry() -> Self {
40        Self {
41            max_retries: Some(0),
42            ..Default::default()
43        }
44    }
45
46    #[must_use]
47    pub fn with_max_retries(mut self, max: u32) -> Self {
48        self.max_retries = Some(max);
49        self
50    }
51
52    #[must_use]
53    pub fn with_initial_backoff(mut self, duration: Duration) -> Self {
54        self.initial_backoff = duration;
55        self
56    }
57
58    #[must_use]
59    pub fn with_max_backoff(mut self, duration: Duration) -> Self {
60        self.max_backoff = duration;
61        self
62    }
63
64    #[must_use]
65    pub fn with_retry_on_status(mut self, status: u16) -> Self {
66        if !self.retry_on_status.contains(&status) {
67            self.retry_on_status.push(status);
68        }
69        self
70    }
71
72    #[must_use]
73    pub fn with_respect_retry_after(mut self, respect: bool) -> Self {
74        self.respect_retry_after = respect;
75        self
76    }
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub enum RetryDecision {
81    Retry(Duration),
82    DontRetry,
83}
84
85#[derive(Debug, Clone)]
86pub struct RetryState {
87    pub attempts: u32,
88    pub current_backoff: Duration,
89    config: RetryConfig,
90}
91
92impl RetryState {
93    pub fn new(config: RetryConfig) -> Self {
94        Self {
95            attempts: 0,
96            current_backoff: config.initial_backoff,
97            config,
98        }
99    }
100
101    pub fn should_retry_error(&mut self, is_abort: bool) -> RetryDecision {
102        if is_abort {
103            return RetryDecision::DontRetry;
104        }
105        self.decide_retry(None)
106    }
107
108    pub fn should_retry_status(
109        &mut self,
110        status: u16,
111        retry_after: Option<Duration>,
112    ) -> RetryDecision {
113        if !self.config.retry_on_status.contains(&status) {
114            return RetryDecision::DontRetry;
115        }
116        self.decide_retry(retry_after)
117    }
118
119    pub fn should_retry_status_with_text(
120        &mut self,
121        status: u16,
122        status_text: Option<&str>,
123        retry_after: Option<Duration>,
124    ) -> RetryDecision {
125        if let Some(text) = status_text {
126            if text.to_lowercase().contains("missing parents") {
127                return self.decide_retry(retry_after);
128            }
129        }
130        self.should_retry_status(status, retry_after)
131    }
132
133    fn decide_retry(&mut self, retry_after: Option<Duration>) -> RetryDecision {
134        self.attempts += 1;
135        if let Some(max) = self.config.max_retries {
136            if self.attempts > max {
137                return RetryDecision::DontRetry;
138            }
139        }
140
141        let wait = if self.config.respect_retry_after {
142            retry_after.unwrap_or(self.current_backoff)
143        } else {
144            self.current_backoff
145        };
146
147        self.current_backoff = std::cmp::min(
148            self.current_backoff + Duration::from_secs(1),
149            self.config.max_backoff,
150        );
151
152        RetryDecision::Retry(wait)
153    }
154
155    pub fn reset(&mut self) {
156        self.attempts = 0;
157        self.current_backoff = self.config.initial_backoff;
158    }
159}
160
161pub fn parse_retry_after(value: &str) -> Option<Duration> {
162    if let Ok(seconds) = value.parse::<u64>() {
163        return Some(Duration::from_secs(seconds));
164    }
165    None
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_default_config() {
174        let config = RetryConfig::default();
175        assert_eq!(config.max_retries, None);
176        assert!(config.retry_on_status.contains(&503));
177    }
178
179    #[test]
180    fn test_retry_state_basic() {
181        let config = RetryConfig::default().with_max_retries(1);
182        let mut state = RetryState::new(config);
183        assert!(matches!(
184            state.should_retry_error(false),
185            RetryDecision::Retry(_)
186        ));
187        assert_eq!(state.should_retry_error(false), RetryDecision::DontRetry);
188    }
189}