pipedash_plugin_api/
utils.rs1use 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}