1use std::{future::Future, time::Duration};
4
5use crate::{
6 apis::{Error, configuration::Configuration},
7 authenticated, authenticated_with_base_url
8};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct RetryPolicy {
18 pub max_retries: u32,
20 pub base_delay: Duration,
22 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 #[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
45fn 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#[derive(Debug, Clone)]
74pub struct TimewebClient {
75 config: Configuration,
76 policy: RetryPolicy
77}
78
79impl TimewebClient {
80 #[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 #[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 #[must_use]
100 pub const fn with_policy(mut self, policy: RetryPolicy) -> Self {
101 self.policy = policy;
102 self
103 }
104
105 #[must_use]
107 pub const fn config(&self) -> &Configuration {
108 &self.config
109 }
110
111 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}