Skip to main content

timeweb_rs/
retry.rs

1//! Retrying client wrapper around the generated API operations.
2
3use std::{future::Future, time::Duration};
4
5use crate::{
6    apis::{Error, configuration::Configuration},
7    authenticated, authenticated_with_base_url
8};
9
10/// When and how often a failed operation is retried.
11///
12/// Retries apply to transport failures, `429 Too Many Requests` and every
13/// `5xx` response; other errors are returned immediately. Delays grow
14/// exponentially from [`RetryPolicy::base_delay`], doubling per attempt and
15/// capped at [`RetryPolicy::max_delay`].
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct RetryPolicy {
18    /// Retries after the initial attempt; `0` disables retrying.
19    pub max_retries: u32,
20    /// Delay before the first retry.
21    pub base_delay:  Duration,
22    /// Upper bound for a single backoff delay.
23    pub max_delay:   Duration
24}
25
26impl Default for RetryPolicy {
27    fn default() -> Self {
28        Self {
29            max_retries: 3,
30            base_delay:  Duration::from_millis(500),
31            max_delay:   Duration::from_secs(8)
32        }
33    }
34}
35
36impl RetryPolicy {
37    /// Backoff delay before the retry with the given zero-based index.
38    #[must_use]
39    pub fn delay_for(&self, retry_index: u32) -> Duration {
40        let factor = 2u32.saturating_pow(retry_index);
41        self.base_delay.saturating_mul(factor).min(self.max_delay)
42    }
43}
44
45/// Whether an operation error is worth retrying.
46fn is_retryable<E>(error: &Error<E>) -> bool {
47    match error {
48        Error::Reqwest(_) => true,
49        Error::ResponseError(response) => {
50            response.status.as_u16() == 429 || response.status.is_server_error()
51        }
52        Error::Serde(_) | Error::Io(_) => false
53    }
54}
55
56/// High-level entry point: an authenticated configuration plus retries.
57///
58/// The generated operations stay free functions over a
59/// [`Configuration`]; this wrapper owns that configuration and re-runs an
60/// operation closure according to its [`RetryPolicy`]:
61///
62/// ```no_run
63/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
64/// use timeweb_rs::{TimewebClient, apis::servers_api};
65///
66/// let client = TimewebClient::new("your-jwt-token");
67/// let servers = client
68///     .execute(|| servers_api::get_servers(client.config(), None, None))
69///     .await?;
70/// # Ok(())
71/// # }
72/// ```
73#[derive(Debug, Clone)]
74pub struct TimewebClient {
75    config: Configuration,
76    policy: RetryPolicy
77}
78
79impl TimewebClient {
80    /// Client for the production API, authenticated with a JWT token.
81    #[must_use]
82    pub fn new(token: impl Into<String>) -> Self {
83        Self {
84            config: authenticated(token),
85            policy: RetryPolicy::default()
86        }
87    }
88
89    /// Client for a custom base URL (mock server, proxy).
90    #[must_use]
91    pub fn with_base_url(token: impl Into<String>, base_url: impl Into<String>) -> Self {
92        Self {
93            config: authenticated_with_base_url(token, base_url),
94            policy: RetryPolicy::default()
95        }
96    }
97
98    /// Replaces the retry policy.
99    #[must_use]
100    pub const fn with_policy(mut self, policy: RetryPolicy) -> Self {
101        self.policy = policy;
102        self
103    }
104
105    /// The configuration to pass to the generated operations.
106    #[must_use]
107    pub const fn config(&self) -> &Configuration {
108        &self.config
109    }
110
111    /// Runs an operation, retrying it under the client's [`RetryPolicy`].
112    ///
113    /// The closure is invoked once per attempt. The final error is returned
114    /// unchanged, so call sites match on [`Error`] exactly as they would
115    /// without retries.
116    ///
117    /// # Errors
118    ///
119    /// Returns the last error once the operation fails with a non-retryable
120    /// error or the policy's retries are exhausted.
121    pub async fn execute<T, E, F, Fut>(&self, operation: F) -> Result<T, Error<E>>
122    where
123        F: Fn() -> Fut,
124        Fut: Future<Output = Result<T, Error<E>>>
125    {
126        let mut retries_left = self.policy.max_retries;
127        let mut retry_index = 0u32;
128        loop {
129            match operation().await {
130                Err(error) if retries_left > 0 && is_retryable(&error) => {
131                    tokio::time::sleep(self.policy.delay_for(retry_index)).await;
132                    retries_left -= 1;
133                    retry_index += 1;
134                }
135                result => return result
136            }
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use std::sync::atomic::{AtomicU32, Ordering};
144
145    use super::{RetryPolicy, TimewebClient, is_retryable};
146    use crate::apis::{Error, ResponseContent};
147
148    fn response_error(status: u16) -> Error<()> {
149        Error::ResponseError(ResponseContent {
150            status:  reqwest::StatusCode::from_u16(status).expect("valid status"),
151            content: String::new(),
152            entity:  None
153        })
154    }
155
156    fn fast_client() -> TimewebClient {
157        TimewebClient::with_base_url("token", "http://localhost:1").with_policy(RetryPolicy {
158            max_retries: 3,
159            base_delay:  std::time::Duration::ZERO,
160            max_delay:   std::time::Duration::ZERO
161        })
162    }
163
164    #[test]
165    fn too_many_requests_and_server_errors_are_retryable() {
166        assert!(is_retryable(&response_error(429)));
167        assert!(is_retryable(&response_error(500)));
168        assert!(is_retryable(&response_error(503)));
169    }
170
171    #[test]
172    fn client_errors_and_decode_errors_are_not_retryable() {
173        assert!(!is_retryable(&response_error(404)));
174        assert!(!is_retryable(&response_error(400)));
175        let serde_error: Error<()> =
176            Error::Serde(serde_json::from_str::<u32>("oops").expect_err("must fail"));
177        assert!(!is_retryable(&serde_error));
178    }
179
180    #[test]
181    fn backoff_doubles_and_saturates_at_the_cap() {
182        let policy = RetryPolicy {
183            max_retries: 5,
184            base_delay:  std::time::Duration::from_millis(100),
185            max_delay:   std::time::Duration::from_millis(350)
186        };
187        assert_eq!(policy.delay_for(0), std::time::Duration::from_millis(100));
188        assert_eq!(policy.delay_for(1), std::time::Duration::from_millis(200));
189        assert_eq!(policy.delay_for(2), std::time::Duration::from_millis(350));
190        assert_eq!(policy.delay_for(9), std::time::Duration::from_millis(350));
191    }
192
193    #[test]
194    fn default_policy_retries_three_times_from_half_a_second() {
195        let policy = RetryPolicy::default();
196        assert_eq!(policy.max_retries, 3);
197        assert_eq!(policy.delay_for(0), std::time::Duration::from_millis(500));
198        assert_eq!(policy.max_delay, std::time::Duration::from_secs(8));
199    }
200
201    #[tokio::test]
202    async fn execute_retries_retryable_errors_until_success() {
203        let attempts = AtomicU32::new(0);
204        let result: Result<u32, Error<()>> = fast_client()
205            .execute(|| {
206                let attempt = attempts.fetch_add(1, Ordering::SeqCst);
207                async move {
208                    if attempt < 2 {
209                        Err(response_error(429))
210                    } else {
211                        Ok(7)
212                    }
213                }
214            })
215            .await;
216        assert_eq!(result.expect("succeeds on the third attempt"), 7);
217        assert_eq!(attempts.load(Ordering::SeqCst), 3);
218    }
219
220    #[tokio::test]
221    async fn execute_stops_on_non_retryable_errors() {
222        let attempts = AtomicU32::new(0);
223        let result: Result<u32, Error<()>> = fast_client()
224            .execute(|| {
225                attempts.fetch_add(1, Ordering::SeqCst);
226                async { Err(response_error(404)) }
227            })
228            .await;
229        assert!(result.is_err());
230        assert_eq!(attempts.load(Ordering::SeqCst), 1);
231    }
232
233    #[tokio::test]
234    async fn execute_exhausts_retries_and_returns_the_last_error() {
235        let attempts = AtomicU32::new(0);
236        let result: Result<u32, Error<()>> = fast_client()
237            .execute(|| {
238                attempts.fetch_add(1, Ordering::SeqCst);
239                async { Err(response_error(503)) }
240            })
241            .await;
242        match result {
243            Err(Error::ResponseError(response)) => assert_eq!(response.status.as_u16(), 503),
244            other => panic!("expected the final 503 to surface, got {other:?}")
245        }
246        assert_eq!(attempts.load(Ordering::SeqCst), 4);
247    }
248
249    #[tokio::test]
250    async fn execute_retries_transport_errors() {
251        let client = fast_client();
252        let result: Result<(), Error<()>> = client
253            .execute(|| async {
254                match reqwest::get("http://127.0.0.1:1/unreachable").await {
255                    Ok(_) => Ok(()),
256                    Err(error) => Err(Error::Reqwest(error))
257                }
258            })
259            .await;
260        assert!(matches!(result, Err(Error::Reqwest(_))));
261    }
262
263    #[test]
264    fn client_exposes_its_configuration() {
265        let client = TimewebClient::new("jwt");
266        assert_eq!(client.config().bearer_access_token.as_deref(), Some("jwt"));
267        let custom = TimewebClient::with_base_url("jwt", "http://localhost:8080");
268        assert_eq!(custom.config().base_path, "http://localhost:8080");
269    }
270}