Skip to main content

pipedash_plugin_api/
utils.rs

1use std::future::Future;
2use std::time::Duration;
3
4use reqwest::Url;
5
6use crate::http::map_send_error;
7use crate::{
8    PluginError,
9    PluginResult,
10};
11
12pub struct RetryPolicy {
13    pub max_retries: usize,
14    pub initial_delay: Duration,
15    pub exponential_backoff: bool,
16}
17
18impl Default for RetryPolicy {
19    fn default() -> Self {
20        Self {
21            max_retries: 3,
22            initial_delay: Duration::from_millis(100),
23            exponential_backoff: true,
24        }
25    }
26}
27
28impl RetryPolicy {
29    pub fn new(max_retries: usize, initial_delay: Duration, exponential_backoff: bool) -> Self {
30        Self {
31            max_retries,
32            initial_delay,
33            exponential_backoff,
34        }
35    }
36
37    pub async fn retry<F, Fut, T>(&self, operation: F) -> PluginResult<T>
38    where
39        F: Fn() -> Fut,
40        Fut: Future<Output = PluginResult<T>>,
41    {
42        let mut delay = self.initial_delay;
43        let mut attempt = 1;
44
45        loop {
46            match operation().await {
47                Ok(result) => return Ok(result),
48                Err(e) if attempt < self.max_retries && Self::should_retry(&e, delay) => {
49                    tokio::time::sleep(delay).await;
50                    delay = self.next_delay(delay);
51                    attempt += 1;
52                }
53                Err(e) => return Err(e),
54            }
55        }
56    }
57
58    pub async fn retry_unsent<F, Fut>(&self, url: &Url, send: F) -> PluginResult<reqwest::Response>
59    where
60        F: Fn() -> Fut,
61        Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
62    {
63        let mut delay = self.initial_delay;
64        let mut attempt = 1;
65
66        loop {
67            match send().await {
68                Ok(response) => return Ok(response),
69                Err(e) if attempt < self.max_retries && e.is_connect() && !e.is_timeout() => {
70                    tokio::time::sleep(delay).await;
71                    delay = self.next_delay(delay);
72                    attempt += 1;
73                }
74                Err(e) => return Err(map_send_error(e, url)),
75            }
76        }
77    }
78
79    fn should_retry(error: &PluginError, delay: Duration) -> bool {
80        error.is_transient() && error.retry_after().is_none_or(|hint| hint <= delay)
81    }
82
83    fn next_delay(&self, delay: Duration) -> Duration {
84        if self.exponential_backoff {
85            delay.saturating_mul(2)
86        } else {
87            delay
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use std::io::{
95        Read,
96        Write,
97    };
98    use std::net::TcpListener;
99    use std::sync::atomic::{
100        AtomicUsize,
101        Ordering,
102    };
103    use std::sync::Arc;
104
105    use super::*;
106
107    fn fast_policy() -> RetryPolicy {
108        RetryPolicy::new(3, Duration::from_millis(5), false)
109    }
110
111    fn client(timeout: Duration) -> reqwest::Client {
112        let _ = rustls::crypto::ring::default_provider().install_default();
113        reqwest::Client::builder().timeout(timeout).build().unwrap()
114    }
115
116    async fn attempts_for(error: fn() -> PluginError) -> usize {
117        let attempts = AtomicUsize::new(0);
118        let result: PluginResult<()> = fast_policy()
119            .retry(|| async {
120                attempts.fetch_add(1, Ordering::SeqCst);
121                Err(error())
122            })
123            .await;
124        assert!(result.is_err());
125        attempts.load(Ordering::SeqCst)
126    }
127
128    #[tokio::test]
129    async fn test_retry_success() {
130        let policy = RetryPolicy::default();
131        let result = policy.retry(|| async { Ok::<_, PluginError>(42) }).await;
132        assert_eq!(result.unwrap(), 42);
133    }
134
135    #[tokio::test]
136    async fn test_retry_eventual_success() {
137        let policy = RetryPolicy::new(3, Duration::from_millis(10), false);
138        let attempts = std::cell::Cell::new(0);
139
140        let result = policy
141            .retry(|| async {
142                let count = attempts.get() + 1;
143                attempts.set(count);
144                if count < 2 {
145                    Err(PluginError::NetworkError("Temporary failure".to_string()))
146                } else {
147                    Ok(42)
148                }
149            })
150            .await;
151
152        assert_eq!(result.unwrap(), 42);
153    }
154
155    #[tokio::test]
156    async fn retries_network_and_short_hint_unavailable() {
157        assert_eq!(
158            attempts_for(|| PluginError::NetworkError("x".into())).await,
159            3
160        );
161        assert_eq!(
162            attempts_for(|| PluginError::ServiceUnavailable {
163                message: "x".into(),
164                retry_after: None,
165            })
166            .await,
167            3
168        );
169        assert_eq!(
170            attempts_for(|| PluginError::ServiceUnavailable {
171                message: "x".into(),
172                retry_after: Some(Duration::from_millis(1)),
173            })
174            .await,
175            3
176        );
177    }
178
179    #[tokio::test]
180    async fn does_not_retry_permanent_errors_or_long_hints() {
181        assert_eq!(
182            attempts_for(|| PluginError::AuthenticationFailed("x".into())).await,
183            1
184        );
185        assert_eq!(
186            attempts_for(|| PluginError::PipelineNotFound("x".into())).await,
187            1
188        );
189        assert_eq!(attempts_for(|| PluginError::ApiError("x".into())).await, 1);
190        assert_eq!(
191            attempts_for(|| PluginError::RateLimited {
192                message: "x".into(),
193                retry_after: Some(Duration::from_secs(120)),
194            })
195            .await,
196            1
197        );
198        assert_eq!(
199            attempts_for(|| PluginError::ServiceUnavailable {
200                message: "x".into(),
201                retry_after: Some(Duration::from_secs(120)),
202            })
203            .await,
204            1
205        );
206    }
207
208    #[tokio::test]
209    async fn retry_unsent_retries_connect_errors() {
210        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
211        let addr = listener.local_addr().unwrap();
212        drop(listener);
213        let url = Url::parse(&format!("http://{addr}/trigger")).unwrap();
214        let client = client(Duration::from_secs(5));
215        let attempts = AtomicUsize::new(0);
216
217        let result = fast_policy()
218            .retry_unsent(&url, || {
219                attempts.fetch_add(1, Ordering::SeqCst);
220                client.post(url.clone()).send()
221            })
222            .await;
223
224        assert!(matches!(result, Err(PluginError::NetworkError(_))));
225        assert_eq!(attempts.load(Ordering::SeqCst), 3);
226    }
227
228    #[tokio::test]
229    async fn retry_unsent_never_resends_after_timeout() {
230        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
231        let addr = listener.local_addr().unwrap();
232        let received = Arc::new(AtomicUsize::new(0));
233        let counter = received.clone();
234        std::thread::spawn(move || {
235            for mut stream in listener.incoming().flatten() {
236                let mut buf = [0u8; 1024];
237                if stream.read(&mut buf).unwrap_or(0) > 0 {
238                    counter.fetch_add(1, Ordering::SeqCst);
239                }
240                std::thread::sleep(Duration::from_millis(300));
241                let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
242            }
243        });
244        let url = Url::parse(&format!("http://{addr}/trigger")).unwrap();
245        let client = client(Duration::from_millis(50));
246
247        let result = fast_policy()
248            .retry_unsent(&url, || client.post(url.clone()).send())
249            .await;
250
251        assert!(matches!(result, Err(PluginError::NetworkError(_))));
252        tokio::time::sleep(Duration::from_millis(100)).await;
253        assert_eq!(received.load(Ordering::SeqCst), 1);
254    }
255}