1use std::future::Future;
7use std::time::Duration;
8
9use rand::RngExt;
10use tracing::debug;
11
12use crate::error::{Result, SeerError};
13
14#[derive(Debug, Clone)]
16pub struct RetryPolicy {
17 pub max_attempts: usize,
19 pub initial_delay: Duration,
21 pub max_delay: Duration,
23 pub multiplier: f64,
25 pub jitter: bool,
27}
28
29impl Default for RetryPolicy {
30 fn default() -> Self {
31 Self {
32 max_attempts: 3,
33 initial_delay: Duration::from_millis(100),
34 max_delay: Duration::from_secs(5),
35 multiplier: 2.0,
36 jitter: true,
37 }
38 }
39}
40
41impl RetryPolicy {
42 pub fn new() -> Self {
44 Self::default()
45 }
46
47 pub fn with_max_attempts(mut self, attempts: usize) -> Self {
49 self.max_attempts = attempts.max(1);
50 self
51 }
52
53 pub fn with_initial_delay(mut self, delay: Duration) -> Self {
55 self.initial_delay = delay;
56 self
57 }
58
59 pub fn with_max_delay(mut self, delay: Duration) -> Self {
61 self.max_delay = delay;
62 self
63 }
64
65 pub fn with_multiplier(mut self, multiplier: f64) -> Self {
67 self.multiplier = multiplier.max(1.0);
68 self
69 }
70
71 pub fn with_jitter(mut self, jitter: bool) -> Self {
73 self.jitter = jitter;
74 self
75 }
76
77 pub fn no_retry() -> Self {
79 Self {
80 max_attempts: 1,
81 ..Self::default()
82 }
83 }
84
85 pub fn delay_for_attempt(&self, attempt: usize) -> Duration {
90 let safe_attempt = attempt.min(20) as i32;
93
94 let base_delay = self.initial_delay.as_millis() as f64 * self.multiplier.powi(safe_attempt);
98 let capped_delay = base_delay.min(self.max_delay.as_millis() as f64);
99
100 let final_delay = if self.jitter {
101 let mut rng = rand::rng();
107 let jitter_factor = rng.random_range(0.0..1.0);
108 capped_delay * jitter_factor
109 } else {
110 capped_delay
111 };
112
113 Duration::from_millis(final_delay as u64)
114 }
115}
116
117pub fn is_retryable(error: &SeerError) -> bool {
130 match error {
131 SeerError::Timeout(_) => true,
133 SeerError::WhoisConnectionFailed(_) => true,
134 SeerError::RateLimited(_) => true,
135
136 SeerError::ReqwestError { transient, .. } => *transient,
139
140 SeerError::WhoisError(msg) => {
142 let lower = msg.to_lowercase();
143 lower.contains("connection")
144 || lower.contains("timeout")
145 || lower.contains("refused")
146 || lower.contains("reset")
147 }
148
149 SeerError::RdapError(msg) => {
151 let lower = msg.to_lowercase();
152 lower.contains("status 5") || lower.contains("status 429") || lower.contains("timeout")
153 }
154
155 SeerError::RdapBootstrapError(msg) => {
161 let lower = msg.to_lowercase();
162 lower.contains("timeout")
163 || lower.contains("connection")
164 || lower.contains("registries failed")
165 }
166
167 SeerError::DnsError(msg) => {
169 let lower = msg.to_lowercase();
170 lower.contains("timeout") || lower.contains("temporary")
171 }
172
173 SeerError::HttpError(msg) => {
175 let lower = msg.to_lowercase();
176 lower.contains("timeout")
177 || lower.contains("connection")
178 || lower.contains("status 5")
179 || lower.contains("status 429")
180 }
181
182 SeerError::InvalidDomain(_) => false,
184 SeerError::DomainNotAllowed { .. } => false,
185 SeerError::InvalidIpAddress(_) => false,
186 SeerError::InvalidRecordType(_) => false,
187 SeerError::WhoisServerNotFound(_) => false,
188 SeerError::JsonError(_) => false,
189 SeerError::CertificateError(_) => false,
190 SeerError::SslError(_) => false,
191 SeerError::DnsResolverError(_) => false,
192 SeerError::LookupFailed { .. } => false,
193 SeerError::ConfigError(_) => false,
194 SeerError::InvalidInput(_) => false,
195 SeerError::RetryExhausted { last_error, .. } => is_retryable(last_error),
200 SeerError::Other(_) => false,
201 }
202}
203
204#[derive(Debug, Clone)]
206pub struct RetryExecutor {
207 policy: RetryPolicy,
208}
209
210impl RetryExecutor {
211 pub fn new(policy: RetryPolicy) -> Self {
213 Self { policy }
214 }
215
216 pub async fn execute<F, Fut, T>(&self, mut operation: F) -> Result<T>
222 where
223 F: FnMut() -> Fut,
224 Fut: Future<Output = Result<T>>,
225 {
226 self.execute_with_delay_hint(|| {
227 let attempt = operation();
228 async move { attempt.await.map_err(|e| (e, None)) }
229 })
230 .await
231 }
232
233 pub async fn execute_with_delay_hint<F, Fut, T>(&self, mut operation: F) -> Result<T>
239 where
240 F: FnMut() -> Fut,
241 Fut: Future<Output = std::result::Result<T, (SeerError, Option<Duration>)>>,
242 {
243 let mut attempt = 0;
244 loop {
245 let (e, hint) = match operation().await {
246 Ok(result) => return Ok(result),
247 Err(failure) => failure,
248 };
249 let attempts_remaining = self.policy.max_attempts.saturating_sub(attempt + 1);
250 if !is_retryable(&e) || attempts_remaining == 0 {
251 if attempt == 0 {
252 return Err(e);
253 }
254 debug!(
255 attempt = attempt + 1,
256 max_attempts = self.policy.max_attempts,
257 error = %e,
258 "Operation failed after retries"
259 );
260 return Err(SeerError::RetryExhausted {
261 attempts: attempt + 1,
262 last_error: Box::new(e),
263 });
264 }
265
266 let delay = match hint {
267 Some(hint) => hint.min(self.policy.max_delay),
268 None => self.policy.delay_for_attempt(attempt),
269 };
270 debug!(
271 attempt = attempt + 1,
272 max_attempts = self.policy.max_attempts,
273 delay_ms = delay.as_millis(),
274 error = %e,
275 "Retrying after transient error"
276 );
277 tokio::time::sleep(delay).await;
278 attempt += 1;
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use std::sync::atomic::{AtomicUsize, Ordering};
287 use std::sync::Arc;
288
289 #[test]
290 fn test_retry_policy_defaults() {
291 let policy = RetryPolicy::default();
292 assert_eq!(policy.max_attempts, 3);
293 assert_eq!(policy.initial_delay, Duration::from_millis(100));
294 assert_eq!(policy.max_delay, Duration::from_secs(5));
295 assert_eq!(policy.multiplier, 2.0);
296 assert!(policy.jitter);
297 }
298
299 #[test]
300 fn test_retry_policy_builder() {
301 let policy = RetryPolicy::new()
302 .with_max_attempts(5)
303 .with_initial_delay(Duration::from_millis(200))
304 .with_max_delay(Duration::from_secs(10))
305 .with_multiplier(3.0)
306 .with_jitter(false);
307
308 assert_eq!(policy.max_attempts, 5);
309 assert_eq!(policy.initial_delay, Duration::from_millis(200));
310 assert_eq!(policy.max_delay, Duration::from_secs(10));
311 assert_eq!(policy.multiplier, 3.0);
312 assert!(!policy.jitter);
313 }
314
315 #[test]
316 fn test_delay_calculation_no_jitter() {
317 let policy = RetryPolicy::new()
318 .with_initial_delay(Duration::from_millis(100))
319 .with_multiplier(2.0)
320 .with_max_delay(Duration::from_secs(10))
321 .with_jitter(false);
322
323 assert_eq!(policy.delay_for_attempt(0), Duration::from_millis(100));
324 assert_eq!(policy.delay_for_attempt(1), Duration::from_millis(200));
325 assert_eq!(policy.delay_for_attempt(2), Duration::from_millis(400));
326 assert_eq!(policy.delay_for_attempt(3), Duration::from_millis(800));
327 }
328
329 #[test]
330 fn test_first_retry_is_jittered() {
331 let policy = RetryPolicy::new()
336 .with_initial_delay(Duration::from_millis(100))
337 .with_jitter(true);
338
339 let delays: Vec<Duration> = (0..64).map(|_| policy.delay_for_attempt(0)).collect();
340 assert!(delays.iter().all(|d| *d <= Duration::from_millis(100)));
342 assert!(
344 delays.iter().any(|d| *d != delays[0]),
345 "attempt-0 delay is constant — jitter not applied to the first retry"
346 );
347 }
348
349 #[test]
350 fn test_delay_capped_at_max() {
351 let policy = RetryPolicy::new()
352 .with_initial_delay(Duration::from_secs(1))
353 .with_multiplier(10.0)
354 .with_max_delay(Duration::from_secs(5))
355 .with_jitter(false);
356
357 assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(5));
359 }
360
361 #[test]
362 fn test_classifier_timeout_is_retryable() {
363 assert!(is_retryable(&SeerError::Timeout("test".to_string())));
364 }
365
366 #[test]
367 fn test_classifier_invalid_domain_not_retryable() {
368 assert!(!is_retryable(&SeerError::InvalidDomain("test".to_string())));
369 }
370
371 #[test]
372 fn test_classifier_server_not_found_not_retryable() {
373 assert!(!is_retryable(&SeerError::WhoisServerNotFound(
374 "test".to_string()
375 )));
376 }
377
378 #[test]
379 fn test_classifier_rate_limited_is_retryable() {
380 assert!(is_retryable(&SeerError::RateLimited("test".to_string())));
381 }
382
383 #[test]
384 fn test_classifier_reads_reqwest_transient_flag() {
385 assert!(is_retryable(&SeerError::ReqwestError {
388 message: "HTTP status server error (503 Service Unavailable)".to_string(),
389 transient: true,
390 }));
391 assert!(!is_retryable(&SeerError::ReqwestError {
392 message: "HTTP status client error (404 Not Found)".to_string(),
393 transient: false,
394 }));
395 }
396
397 #[tokio::test]
398 async fn test_executor_success_on_first_try() {
399 let policy = RetryPolicy::new().with_max_attempts(3);
400 let executor = RetryExecutor::new(policy);
401 let attempts = Arc::new(AtomicUsize::new(0));
402
403 let attempts_clone = attempts.clone();
404 let result: Result<&str> = executor
405 .execute(|| {
406 let a = attempts_clone.clone();
407 async move {
408 a.fetch_add(1, Ordering::SeqCst);
409 Ok("success")
410 }
411 })
412 .await;
413
414 assert!(result.is_ok());
415 assert_eq!(result.unwrap(), "success");
416 assert_eq!(attempts.load(Ordering::SeqCst), 1);
417 }
418
419 #[tokio::test]
420 async fn test_executor_retries_on_transient_error() {
421 let policy = RetryPolicy::new()
422 .with_max_attempts(3)
423 .with_initial_delay(Duration::from_millis(1))
424 .with_jitter(false);
425 let executor = RetryExecutor::new(policy);
426 let attempts = Arc::new(AtomicUsize::new(0));
427
428 let attempts_clone = attempts.clone();
429 let result: Result<&str> = executor
430 .execute(|| {
431 let a = attempts_clone.clone();
432 async move {
433 let count = a.fetch_add(1, Ordering::SeqCst);
434 if count < 2 {
435 Err(SeerError::Timeout("test timeout".to_string()))
436 } else {
437 Ok("success after retries")
438 }
439 }
440 })
441 .await;
442
443 assert!(result.is_ok());
444 assert_eq!(result.unwrap(), "success after retries");
445 assert_eq!(attempts.load(Ordering::SeqCst), 3);
446 }
447
448 #[tokio::test]
449 async fn test_executor_no_retry_on_non_retryable_error() {
450 let policy = RetryPolicy::new()
451 .with_max_attempts(3)
452 .with_initial_delay(Duration::from_millis(1));
453 let executor = RetryExecutor::new(policy);
454 let attempts = Arc::new(AtomicUsize::new(0));
455
456 let attempts_clone = attempts.clone();
457 let result: Result<&str> = executor
458 .execute(|| {
459 let a = attempts_clone.clone();
460 async move {
461 a.fetch_add(1, Ordering::SeqCst);
462 Err(SeerError::InvalidDomain("bad.".to_string()))
463 }
464 })
465 .await;
466
467 assert!(result.is_err());
468 assert_eq!(attempts.load(Ordering::SeqCst), 1);
470 }
471
472 #[tokio::test]
473 async fn test_executor_exhausts_retries() {
474 let policy = RetryPolicy::new()
475 .with_max_attempts(3)
476 .with_initial_delay(Duration::from_millis(1))
477 .with_jitter(false);
478 let executor = RetryExecutor::new(policy);
479 let attempts = Arc::new(AtomicUsize::new(0));
480
481 let attempts_clone = attempts.clone();
482 let result: Result<&str> = executor
483 .execute(|| {
484 let a = attempts_clone.clone();
485 async move {
486 a.fetch_add(1, Ordering::SeqCst);
487 Err(SeerError::Timeout("always fails".to_string()))
488 }
489 })
490 .await;
491
492 assert!(result.is_err());
493 assert_eq!(attempts.load(Ordering::SeqCst), 3);
494
495 match result.unwrap_err() {
497 SeerError::RetryExhausted { attempts, .. } => {
498 assert_eq!(attempts, 3);
499 }
500 other => panic!("Expected RetryExhausted, got {:?}", other),
501 }
502 }
503
504 #[tokio::test(start_paused = true)]
505 async fn delay_hint_replaces_backoff_and_is_capped_at_max_delay() {
506 let policy = RetryPolicy::new()
509 .with_max_attempts(2)
510 .with_initial_delay(Duration::from_millis(1))
511 .with_max_delay(Duration::from_secs(2))
512 .with_jitter(false);
513 let attempts = Arc::new(AtomicUsize::new(0));
514 let start = tokio::time::Instant::now();
515 let a = attempts.clone();
516 let result: Result<&str> = RetryExecutor::new(policy)
517 .execute_with_delay_hint(|| {
518 let a = a.clone();
519 async move {
520 if a.fetch_add(1, Ordering::SeqCst) == 0 {
521 Err((
522 SeerError::RateLimited("429".to_string()),
523 Some(Duration::from_secs(60)),
524 ))
525 } else {
526 Ok("ok")
527 }
528 }
529 })
530 .await;
531 assert_eq!(result.unwrap(), "ok");
532 assert_eq!(attempts.load(Ordering::SeqCst), 2);
533 assert_eq!(start.elapsed(), Duration::from_secs(2));
534 }
535
536 #[tokio::test(start_paused = true)]
537 async fn short_delay_hint_is_honored_over_backoff() {
538 let policy = RetryPolicy::new()
539 .with_max_attempts(2)
540 .with_initial_delay(Duration::from_secs(4))
541 .with_jitter(false);
542 let start = tokio::time::Instant::now();
543 let first = Arc::new(AtomicUsize::new(0));
544 let result: Result<()> = RetryExecutor::new(policy)
545 .execute_with_delay_hint(|| {
546 let first = first.clone();
547 async move {
548 if first.fetch_add(1, Ordering::SeqCst) == 0 {
549 Err((
550 SeerError::RateLimited("429".to_string()),
551 Some(Duration::from_secs(1)),
552 ))
553 } else {
554 Ok(())
555 }
556 }
557 })
558 .await;
559 assert!(result.is_ok());
560 assert_eq!(start.elapsed(), Duration::from_secs(1));
561 }
562
563 #[test]
564 fn test_no_retry_policy() {
565 let policy = RetryPolicy::no_retry();
566 assert_eq!(policy.max_attempts, 1);
567 }
568
569 #[test]
570 fn test_delay_overflow_protection() {
571 let policy = RetryPolicy::new()
572 .with_initial_delay(Duration::from_millis(100))
573 .with_multiplier(2.0)
574 .with_max_delay(Duration::from_secs(5))
575 .with_jitter(false);
576
577 let delay_50 = policy.delay_for_attempt(50);
579 let delay_100 = policy.delay_for_attempt(100);
580 let delay_1000 = policy.delay_for_attempt(1000);
581
582 assert!(delay_50 <= Duration::from_secs(5));
584 assert!(delay_100 <= Duration::from_secs(5));
585 assert!(delay_1000 <= Duration::from_secs(5));
586 }
587
588 #[test]
589 fn retry_exhausted_is_retryable_if_inner_is() {
590 let retryable_inner = SeerError::Timeout("inner timed out".to_string());
596 let wrapped_retryable = SeerError::RetryExhausted {
597 attempts: 3,
598 last_error: Box::new(retryable_inner),
599 };
600 assert!(
601 is_retryable(&wrapped_retryable),
602 "RetryExhausted wrapping a retryable Timeout should be retryable",
603 );
604
605 let non_retryable_inner = SeerError::InvalidDomain("bad.".to_string());
606 let wrapped_non_retryable = SeerError::RetryExhausted {
607 attempts: 3,
608 last_error: Box::new(non_retryable_inner),
609 };
610 assert!(
611 !is_retryable(&wrapped_non_retryable),
612 "RetryExhausted wrapping a non-retryable InvalidDomain must not be retryable",
613 );
614 }
615
616 #[test]
617 fn bootstrap_all_registries_failed_is_retryable() {
618 assert!(is_retryable(&SeerError::RdapBootstrapError(
622 "all IANA bootstrap registries failed".to_string()
623 )));
624 assert!(!is_retryable(&SeerError::RdapBootstrapError(
626 "no RDAP server for example.ru".to_string()
627 )));
628 assert!(!is_retryable(&SeerError::RdapBootstrapError(
629 "bootstrap refresh throttled and no cache available".to_string()
630 )));
631 }
632}