Skip to main content

seer_core/
retry.rs

1//! Retry logic with exponential backoff for transient failures.
2//!
3//! This module provides configurable retry policies and executors for handling
4//! transient network failures in WHOIS and RDAP lookups.
5
6use std::future::Future;
7use std::time::Duration;
8
9use rand::RngExt;
10use tracing::debug;
11
12use crate::error::{Result, SeerError};
13
14/// Configuration for retry behavior with exponential backoff.
15#[derive(Debug, Clone)]
16pub struct RetryPolicy {
17    /// Maximum number of attempts (including the initial attempt).
18    pub max_attempts: usize,
19    /// Initial delay before the first retry.
20    pub initial_delay: Duration,
21    /// Maximum delay between retries (caps exponential growth).
22    pub max_delay: Duration,
23    /// Multiplier for exponential backoff (delay *= multiplier after each retry).
24    pub multiplier: f64,
25    /// Whether to add random jitter to delays to avoid thundering herd.
26    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    /// Creates a new retry policy with default settings.
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Sets the maximum number of attempts.
48    pub fn with_max_attempts(mut self, attempts: usize) -> Self {
49        self.max_attempts = attempts.max(1);
50        self
51    }
52
53    /// Sets the initial delay before the first retry.
54    pub fn with_initial_delay(mut self, delay: Duration) -> Self {
55        self.initial_delay = delay;
56        self
57    }
58
59    /// Sets the maximum delay between retries.
60    pub fn with_max_delay(mut self, delay: Duration) -> Self {
61        self.max_delay = delay;
62        self
63    }
64
65    /// Sets the multiplier for exponential backoff.
66    pub fn with_multiplier(mut self, multiplier: f64) -> Self {
67        self.multiplier = multiplier.max(1.0);
68        self
69    }
70
71    /// Enables or disables jitter.
72    pub fn with_jitter(mut self, jitter: bool) -> Self {
73        self.jitter = jitter;
74        self
75    }
76
77    /// Creates a policy that disables retries (single attempt only).
78    pub fn no_retry() -> Self {
79        Self {
80            max_attempts: 1,
81            ..Self::default()
82        }
83    }
84
85    /// Calculates the delay for a given attempt number (0-indexed).
86    ///
87    /// The attempt number is internally capped to prevent integer overflow
88    /// in the exponential calculation.
89    pub fn delay_for_attempt(&self, attempt: usize) -> Duration {
90        // Cap attempt to prevent overflow in powi() - 20 attempts with multiplier 2.0
91        // gives 2^20 = ~1 million, which is safe for f64 and reasonable for delays
92        let safe_attempt = attempt.min(20) as i32;
93
94        // powi(0) == 1.0, so attempt 0 naturally yields initial_delay — no
95        // short-circuit, which would skip the jitter branch below on the very
96        // attempt where concurrent callers are most likely in lockstep.
97        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            // Full jitter (AWS "Exponential Backoff and Jitter"):
102            // sleep ∈ [0, capped_delay]. Half-jitter ([0.5..1.0]) clusters
103            // retries in the upper half of the window, which leaves
104            // measurable thundering-herd behaviour after a brief outage.
105            // Full jitter spreads retries uniformly across the window.
106            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
117/// Whether `error` is transient, so a WHOIS/RDAP operation is worth retrying.
118///
119/// Retryable:
120/// - Timeouts
121/// - Connection failures (IO errors)
122/// - Rate limiting (429)
123/// - Server errors (5xx)
124///
125/// Not retryable:
126/// - Invalid input (domain, IP, record type)
127/// - Server not found
128/// - Parse errors (JSON, WHOIS format)
129pub fn is_retryable(error: &SeerError) -> bool {
130    match error {
131        // Transient errors - worth retrying
132        SeerError::Timeout(_) => true,
133        SeerError::WhoisConnectionFailed(_) => true,
134        SeerError::RateLimited(_) => true,
135
136        // Transiency was already classified at the From<reqwest::Error>
137        // boundary (see error.rs); just read the stored flag.
138        SeerError::ReqwestError { transient, .. } => *transient,
139
140        // WHOIS errors might be transient if they're connection-related
141        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        // RDAP errors might be transient server errors
150        SeerError::RdapError(msg) => {
151            let lower = msg.to_lowercase();
152            lower.contains("status 5") || lower.contains("status 429") || lower.contains("timeout")
153        }
154
155        // Bootstrap errors could be transient if IANA is temporarily
156        // unavailable. "all IANA bootstrap registries failed" is exactly
157        // that case (every fetch errored), so it must be retryable —
158        // otherwise the bootstrap loader's retry policy never retries.
159        // "no RDAP server for ..." and "throttled and no cache" are not.
160        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        // DNS errors can be transient
168        SeerError::DnsError(msg) => {
169            let lower = msg.to_lowercase();
170            lower.contains("timeout") || lower.contains("temporary")
171        }
172
173        // HTTP errors might be transient (server errors 5xx or 429 Too Many Requests)
174        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        // Not retryable - permanent failures
183        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        // Transparent pass-through: if a prior attempt was wrapped into
196        // RetryExhausted upstream, defer the retryable decision to the
197        // underlying cause so a caller layering retries still sees the
198        // true fault classification instead of a non-retryable wrapper.
199        SeerError::RetryExhausted { last_error, .. } => is_retryable(last_error),
200        SeerError::Other(_) => false,
201    }
202}
203
204/// Executes operations with retry logic using exponential backoff.
205#[derive(Debug, Clone)]
206pub struct RetryExecutor {
207    policy: RetryPolicy,
208}
209
210impl RetryExecutor {
211    /// Creates a new executor for `policy`.
212    pub fn new(policy: RetryPolicy) -> Self {
213        Self { policy }
214    }
215
216    /// Executes an async operation with retry logic.
217    ///
218    /// The operation will be retried up to `max_attempts` times if it fails
219    /// with a retryable error (see [`is_retryable`]). Delays between retries
220    /// follow exponential backoff with optional jitter.
221    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    /// Like [`execute`](Self::execute), but each failure may carry a
234    /// server-suggested delay (an HTTP 429 `Retry-After`). A hint replaces the
235    /// backoff for that retry, capped at the policy's
236    /// [`max_delay`](RetryPolicy::max_delay) so a hostile or misconfigured
237    /// header cannot pin the caller.
238    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        // The first retry (attempt 0) is exactly where a shared outage or
332        // 429 hits every concurrent caller at once, so full jitter matters
333        // most there. A fixed `initial_delay` short-circuit produced a
334        // thundering herd of lockstep retries (2026-07-11 review).
335        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        // Full jitter: every sample within [0, initial_delay] ...
341        assert!(delays.iter().all(|d| *d <= Duration::from_millis(100)));
342        // ... and actually randomized (64 identical samples ≈ impossible).
343        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        // 1s * 10^2 = 100s, but capped at 5s
358        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        // Transiency is classified once at the From<reqwest::Error> boundary
386        // (tested in error.rs); the classifier only reads the stored flag.
387        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        // Should only attempt once since InvalidDomain is not retryable
469        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        // Check that we get RetryExhausted error
496        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        // A server hint (Retry-After) wins over the backoff, but never waits
507        // past `max_delay`: the 60s hint here is honored as 2s.
508        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        // Test with very large attempt numbers - should not panic or produce invalid durations
578        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        // All should be capped at max_delay due to our overflow protection
583        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        // Regression for H5: the retry classifier must look through a
591        // RetryExhausted wrapper so a caller layering retries can still see
592        // the true underlying fault classification instead of collapsing to
593        // a non-retryable wrapper.
594
595        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        // The RDAP bootstrap loader retries through RetryExecutor; if its
619        // "every IANA registry fetch failed" error isn't classified as
620        // transient, the retry policy never gets a second attempt.
621        assert!(is_retryable(&SeerError::RdapBootstrapError(
622            "all IANA bootstrap registries failed".to_string()
623        )));
624        // Permanent bootstrap outcomes stay non-retryable.
625        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}