Skip to main content

aptos_sdk/
retry.rs

1//! Automatic retry with exponential backoff.
2//!
3//! This module provides retry functionality for handling transient failures
4//! in API calls. It implements exponential backoff with optional jitter to
5//! prevent thundering herd problems.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use aptos_sdk::retry::{RetryConfig, RetryPolicy};
11//!
12//! // Create a custom retry policy
13//! let config = RetryConfig::builder()
14//!     .max_retries(5)
15//!     .initial_delay_ms(100)
16//!     .max_delay_ms(10_000)
17//!     .exponential_base(2.0)
18//!     .jitter(true)
19//!     .build();
20//!
21//! // Use with the Aptos client
22//! let aptos = Aptos::new(AptosConfig::testnet().with_retry(config))?;
23//! ```
24
25use crate::error::{AptosError, AptosResult};
26use std::collections::HashSet;
27use std::future::Future;
28use std::sync::Arc;
29use std::time::Duration;
30use tokio::time::sleep;
31
32/// Configuration for retry behavior.
33#[derive(Debug, Clone)]
34pub struct RetryConfig {
35    /// Maximum number of retry attempts (0 = no retries).
36    pub max_retries: u32,
37    /// Initial delay before the first retry (in milliseconds).
38    pub initial_delay_ms: u64,
39    /// Maximum delay between retries (in milliseconds).
40    pub max_delay_ms: u64,
41    /// Base for exponential backoff (typically 2.0).
42    pub exponential_base: f64,
43    /// Whether to add random jitter to delays.
44    pub jitter: bool,
45    /// Jitter factor (0.0 to 1.0) - how much randomness to add.
46    pub jitter_factor: f64,
47    /// HTTP status codes that should trigger a retry.
48    /// Uses `HashSet` for O(1) lookups instead of O(n) linear search.
49    pub retryable_status_codes: HashSet<u16>,
50}
51
52impl Default for RetryConfig {
53    fn default() -> Self {
54        Self {
55            max_retries: 3,
56            initial_delay_ms: 100,
57            max_delay_ms: 10_000,
58            exponential_base: 2.0,
59            jitter: true,
60            jitter_factor: 0.5,
61            retryable_status_codes: [
62                408, // Request Timeout
63                429, // Too Many Requests
64                500, // Internal Server Error
65                502, // Bad Gateway
66                503, // Service Unavailable
67                504, // Gateway Timeout
68            ]
69            .into_iter()
70            .collect(),
71        }
72    }
73}
74
75impl RetryConfig {
76    /// Creates a new builder for `RetryConfig`.
77    pub fn builder() -> RetryConfigBuilder {
78        RetryConfigBuilder::default()
79    }
80
81    /// Creates a config with no retries (fail fast).
82    pub fn no_retry() -> Self {
83        Self {
84            max_retries: 0,
85            ..Default::default()
86        }
87    }
88
89    /// Creates a config optimized for aggressive retrying.
90    pub fn aggressive() -> Self {
91        Self {
92            max_retries: 5,
93            initial_delay_ms: 50,
94            max_delay_ms: 5_000,
95            exponential_base: 1.5,
96            jitter: true,
97            jitter_factor: 0.3,
98            ..Default::default()
99        }
100    }
101
102    /// Creates a config optimized for conservative retrying.
103    pub fn conservative() -> Self {
104        Self {
105            max_retries: 3,
106            initial_delay_ms: 500,
107            max_delay_ms: 30_000,
108            exponential_base: 2.0,
109            jitter: true,
110            jitter_factor: 0.5,
111            ..Default::default()
112        }
113    }
114
115    /// Calculates the delay for a given attempt number.
116    #[allow(clippy::cast_possible_truncation)] // Delay is bounded by max_delay_ms
117    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
118        if attempt == 0 {
119            return Duration::from_millis(0);
120        }
121
122        // Calculate base delay with exponential backoff
123        #[allow(clippy::cast_precision_loss)] // Delay is bounded by max_delay_ms
124        let base_delay = self.initial_delay_ms as f64
125            * self
126                .exponential_base
127                .powi(attempt.saturating_sub(1).cast_signed());
128
129        // Cap at max delay
130        #[allow(clippy::cast_precision_loss)] // Delay is bounded by max_delay_ms
131        let capped_delay = base_delay.min(self.max_delay_ms as f64);
132
133        // Add jitter if enabled
134        let final_delay = if self.jitter {
135            let jitter_range = capped_delay * self.jitter_factor;
136            let jitter = rand::random::<f64>() * jitter_range * 2.0 - jitter_range;
137            (capped_delay + jitter).max(0.0)
138        } else {
139            capped_delay
140        };
141
142        #[allow(clippy::cast_sign_loss)] // Delay is bounded by max_delay_ms
143        Duration::from_millis(final_delay as u64)
144    }
145
146    /// Checks if a status code should trigger a retry.
147    #[inline]
148    pub fn is_retryable_status(&self, status_code: u16) -> bool {
149        self.retryable_status_codes.contains(&status_code)
150    }
151
152    /// Checks if an error should trigger a retry.
153    #[inline]
154    pub fn is_retryable_error(&self, error: &AptosError) -> bool {
155        match error {
156            // Network errors are typically transient
157            AptosError::Http(_) | AptosError::RateLimited { .. } => true,
158            // API errors with retryable status codes
159            AptosError::Api { status_code, .. } => self.is_retryable_status(*status_code),
160            // Other errors are not retried
161            _ => false,
162        }
163    }
164}
165
166/// Builder for `RetryConfig`.
167#[derive(Debug, Clone, Default)]
168pub struct RetryConfigBuilder {
169    max_retries: Option<u32>,
170    initial_delay_ms: Option<u64>,
171    max_delay_ms: Option<u64>,
172    exponential_base: Option<f64>,
173    jitter: Option<bool>,
174    jitter_factor: Option<f64>,
175    retryable_status_codes: Option<HashSet<u16>>,
176}
177
178impl RetryConfigBuilder {
179    /// Sets the maximum number of retry attempts.
180    #[must_use]
181    pub fn max_retries(mut self, max_retries: u32) -> Self {
182        self.max_retries = Some(max_retries);
183        self
184    }
185
186    /// Sets the initial delay before the first retry (in milliseconds).
187    #[must_use]
188    pub fn initial_delay_ms(mut self, initial_delay_ms: u64) -> Self {
189        self.initial_delay_ms = Some(initial_delay_ms);
190        self
191    }
192
193    /// Sets the maximum delay between retries (in milliseconds).
194    #[must_use]
195    pub fn max_delay_ms(mut self, max_delay_ms: u64) -> Self {
196        self.max_delay_ms = Some(max_delay_ms);
197        self
198    }
199
200    /// Sets the base for exponential backoff.
201    #[must_use]
202    pub fn exponential_base(mut self, base: f64) -> Self {
203        self.exponential_base = Some(base);
204        self
205    }
206
207    /// Enables or disables jitter.
208    #[must_use]
209    pub fn jitter(mut self, jitter: bool) -> Self {
210        self.jitter = Some(jitter);
211        self
212    }
213
214    /// Sets the jitter factor (0.0 to 1.0).
215    #[must_use]
216    pub fn jitter_factor(mut self, factor: f64) -> Self {
217        self.jitter_factor = Some(factor.clamp(0.0, 1.0));
218        self
219    }
220
221    /// Sets the HTTP status codes that should trigger a retry.
222    #[must_use]
223    pub fn retryable_status_codes(mut self, codes: impl IntoIterator<Item = u16>) -> Self {
224        self.retryable_status_codes = Some(codes.into_iter().collect());
225        self
226    }
227
228    /// Adds a status code to the set of retryable codes.
229    #[must_use]
230    pub fn add_retryable_status_code(mut self, code: u16) -> Self {
231        let mut codes = self.retryable_status_codes.unwrap_or_default();
232        codes.insert(code);
233        self.retryable_status_codes = Some(codes);
234        self
235    }
236
237    /// Builds the `RetryConfig`.
238    #[must_use]
239    pub fn build(self) -> RetryConfig {
240        let default = RetryConfig::default();
241        RetryConfig {
242            max_retries: self.max_retries.unwrap_or(default.max_retries),
243            initial_delay_ms: self.initial_delay_ms.unwrap_or(default.initial_delay_ms),
244            max_delay_ms: self.max_delay_ms.unwrap_or(default.max_delay_ms),
245            exponential_base: self.exponential_base.unwrap_or(default.exponential_base),
246            jitter: self.jitter.unwrap_or(default.jitter),
247            jitter_factor: self.jitter_factor.unwrap_or(default.jitter_factor),
248            retryable_status_codes: self
249                .retryable_status_codes
250                .unwrap_or(default.retryable_status_codes),
251        }
252    }
253}
254
255/// Executes an async operation with automatic retry.
256#[derive(Debug, Clone)]
257pub struct RetryExecutor {
258    config: Arc<RetryConfig>,
259}
260
261impl RetryExecutor {
262    /// Creates a new retry executor with the given config.
263    pub fn new(config: RetryConfig) -> Self {
264        Self {
265            config: Arc::new(config),
266        }
267    }
268
269    /// Creates a retry executor from a shared config, avoiding a clone.
270    pub fn from_shared(config: Arc<RetryConfig>) -> Self {
271        Self { config }
272    }
273
274    /// Creates a retry executor with default config.
275    pub fn with_defaults() -> Self {
276        Self::new(RetryConfig::default())
277    }
278
279    /// Executes an async operation with retry logic.
280    ///
281    /// The operation will be retried if it returns a retryable error,
282    /// up to the configured maximum number of retries.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the operation fails and either the maximum number of
287    /// retries has been exhausted or the error is not retryable according to
288    /// the retry configuration.
289    pub async fn execute<F, Fut, T>(&self, operation: F) -> AptosResult<T>
290    where
291        F: Fn() -> Fut,
292        Fut: Future<Output = AptosResult<T>>,
293    {
294        let mut attempt = 0;
295
296        loop {
297            match operation().await {
298                Ok(result) => return Ok(result),
299                Err(error) => {
300                    // Check if we should retry
301                    if attempt >= self.config.max_retries || !self.config.is_retryable_error(&error)
302                    {
303                        return Err(error);
304                    }
305
306                    attempt += 1;
307
308                    // SECURITY: Respect Retry-After header for rate limiting
309                    // This prevents aggressive retries that could worsen rate limiting
310                    let delay = if let AptosError::RateLimited {
311                        retry_after_secs: Some(secs),
312                    } = &error
313                    {
314                        // Use the server's Retry-After value, but cap it to prevent DoS
315                        let capped_secs = (*secs).min(300); // Max 5 minutes
316                        Duration::from_secs(capped_secs)
317                    } else {
318                        self.config.delay_for_attempt(attempt)
319                    };
320
321                    if !delay.is_zero() {
322                        sleep(delay).await;
323                    }
324                }
325            }
326        }
327    }
328
329    /// Executes an async operation with retry logic and a custom retry predicate.
330    ///
331    /// # Errors
332    ///
333    /// Returns an error if the operation fails and either the maximum number of
334    /// retries has been exhausted or the custom retry predicate returns `false`.
335    pub async fn execute_with_predicate<F, Fut, T, P>(
336        &self,
337        operation: F,
338        should_retry: P,
339    ) -> AptosResult<T>
340    where
341        F: Fn() -> Fut,
342        Fut: Future<Output = AptosResult<T>>,
343        P: Fn(&AptosError) -> bool,
344    {
345        let mut attempt = 0;
346
347        loop {
348            match operation().await {
349                Ok(result) => return Ok(result),
350                Err(error) => {
351                    if attempt >= self.config.max_retries || !should_retry(&error) {
352                        return Err(error);
353                    }
354
355                    attempt += 1;
356
357                    // SECURITY: Respect Retry-After header for rate limiting
358                    let delay = if let AptosError::RateLimited {
359                        retry_after_secs: Some(secs),
360                    } = &error
361                    {
362                        let capped_secs = (*secs).min(300);
363                        Duration::from_secs(capped_secs)
364                    } else {
365                        self.config.delay_for_attempt(attempt)
366                    };
367
368                    if !delay.is_zero() {
369                        sleep(delay).await;
370                    }
371                }
372            }
373        }
374    }
375}
376
377/// Extension trait for adding retry capability to async operations.
378///
379/// A single [`Future`] cannot be retried because it is consumed the first
380/// time it is polled to completion. Retrying therefore requires a *factory*
381/// closure that can produce a fresh future on every attempt, so this trait is
382/// implemented for `Fn() -> Future` operation factories rather than for bare
383/// futures.
384///
385/// # Example
386///
387/// ```rust,ignore
388/// use aptos_sdk::retry::{RetryConfig, RetryExt};
389///
390/// let config = RetryConfig::default();
391/// let result = (|| async { fetch_something().await }).with_retry(&config).await;
392/// ```
393pub trait RetryExt<T> {
394    /// Executes this operation factory under the given retry config.
395    ///
396    /// The closure is invoked once per attempt, producing a fresh future each
397    /// time, and retried according to `config` while the returned error is
398    /// retryable.
399    fn with_retry(self, config: &RetryConfig) -> impl Future<Output = AptosResult<T>>;
400}
401
402impl<F, Fut, T> RetryExt<T> for F
403where
404    F: Fn() -> Fut,
405    Fut: Future<Output = AptosResult<T>>,
406{
407    fn with_retry(self, config: &RetryConfig) -> impl Future<Output = AptosResult<T>> {
408        let executor = RetryExecutor::new(config.clone());
409        async move { executor.execute(self).await }
410    }
411}
412
413/// Convenience function to retry an operation with default config.
414///
415/// # Errors
416///
417/// Returns an error if the operation fails and either the maximum number of
418/// retries has been exhausted or the error is not retryable according to
419/// the default retry configuration.
420pub async fn retry<F, Fut, T>(operation: F) -> AptosResult<T>
421where
422    F: Fn() -> Fut,
423    Fut: Future<Output = AptosResult<T>>,
424{
425    RetryExecutor::with_defaults().execute(operation).await
426}
427
428/// Convenience function to retry an operation with custom config.
429///
430/// # Errors
431///
432/// Returns an error if the operation fails and either the maximum number of
433/// retries has been exhausted or the error is not retryable according to
434/// the provided retry configuration.
435pub async fn retry_with_config<F, Fut, T>(config: &RetryConfig, operation: F) -> AptosResult<T>
436where
437    F: Fn() -> Fut,
438    Fut: Future<Output = AptosResult<T>>,
439{
440    RetryExecutor::new(config.clone()).execute(operation).await
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use std::sync::Arc;
447    use std::sync::atomic::{AtomicU32, Ordering};
448
449    #[test]
450    fn test_default_config() {
451        let config = RetryConfig::default();
452        assert_eq!(config.max_retries, 3);
453        assert_eq!(config.initial_delay_ms, 100);
454        assert!(config.jitter);
455    }
456
457    #[test]
458    fn test_no_retry_config() {
459        let config = RetryConfig::no_retry();
460        assert_eq!(config.max_retries, 0);
461    }
462
463    #[test]
464    fn test_builder() {
465        let config = RetryConfig::builder()
466            .max_retries(5)
467            .initial_delay_ms(200)
468            .max_delay_ms(5000)
469            .exponential_base(1.5)
470            .jitter(false)
471            .build();
472
473        assert_eq!(config.max_retries, 5);
474        assert_eq!(config.initial_delay_ms, 200);
475        assert_eq!(config.max_delay_ms, 5000);
476        assert!((config.exponential_base - 1.5).abs() < f64::EPSILON);
477        assert!(!config.jitter);
478    }
479
480    #[test]
481    fn test_delay_calculation_no_jitter() {
482        let config = RetryConfig::builder()
483            .initial_delay_ms(100)
484            .exponential_base(2.0)
485            .jitter(false)
486            .build();
487
488        // Attempt 0 should have no delay
489        assert_eq!(config.delay_for_attempt(0), Duration::from_millis(0));
490
491        // Attempt 1: 100ms
492        assert_eq!(config.delay_for_attempt(1), Duration::from_millis(100));
493
494        // Attempt 2: 100 * 2^1 = 200ms
495        assert_eq!(config.delay_for_attempt(2), Duration::from_millis(200));
496
497        // Attempt 3: 100 * 2^2 = 400ms
498        assert_eq!(config.delay_for_attempt(3), Duration::from_millis(400));
499    }
500
501    #[test]
502    fn test_delay_capped_at_max() {
503        let config = RetryConfig::builder()
504            .initial_delay_ms(1000)
505            .max_delay_ms(2000)
506            .exponential_base(2.0)
507            .jitter(false)
508            .build();
509
510        // Attempt 3 would be 1000 * 2^2 = 4000ms, but capped at 2000ms
511        assert_eq!(config.delay_for_attempt(3), Duration::from_secs(2));
512    }
513
514    #[test]
515    fn test_retryable_status_codes() {
516        let config = RetryConfig::default();
517
518        assert!(config.is_retryable_status(429)); // Too Many Requests
519        assert!(config.is_retryable_status(503)); // Service Unavailable
520        assert!(!config.is_retryable_status(400)); // Bad Request
521        assert!(!config.is_retryable_status(404)); // Not Found
522    }
523
524    #[test]
525    fn test_retryable_errors() {
526        let config = RetryConfig::default();
527
528        // API errors with retryable status codes
529        let api_error = AptosError::Api {
530            status_code: 503,
531            message: "Service Unavailable".to_string(),
532            error_code: None,
533            vm_error_code: None,
534        };
535        assert!(config.is_retryable_error(&api_error));
536
537        // Rate limited error
538        let rate_limited = AptosError::RateLimited {
539            retry_after_secs: Some(30),
540        };
541        assert!(config.is_retryable_error(&rate_limited));
542
543        // API errors with non-retryable status codes
544        let api_error_400 = AptosError::Api {
545            status_code: 400,
546            message: "Bad Request".to_string(),
547            error_code: None,
548            vm_error_code: None,
549        };
550        assert!(!config.is_retryable_error(&api_error_400));
551
552        // Not found is not retryable
553        let not_found = AptosError::NotFound("resource".to_string());
554        assert!(!config.is_retryable_error(&not_found));
555    }
556
557    #[tokio::test]
558    async fn test_retry_succeeds_on_first_try() {
559        let executor = RetryExecutor::with_defaults();
560        let counter = Arc::new(AtomicU32::new(0));
561        let counter_clone = counter.clone();
562
563        let result = executor
564            .execute(|| {
565                let counter = counter_clone.clone();
566                async move {
567                    counter.fetch_add(1, Ordering::SeqCst);
568                    Ok::<_, AptosError>(42)
569                }
570            })
571            .await;
572
573        assert_eq!(result.unwrap(), 42);
574        assert_eq!(counter.load(Ordering::SeqCst), 1);
575    }
576
577    #[tokio::test]
578    async fn test_retry_succeeds_after_failures() {
579        let config = RetryConfig::builder()
580            .max_retries(3)
581            .initial_delay_ms(1) // Very short for testing
582            .jitter(false)
583            .build();
584        let executor = RetryExecutor::new(config);
585        let counter = Arc::new(AtomicU32::new(0));
586        let counter_clone = counter.clone();
587
588        let result = executor
589            .execute(|| {
590                let counter = counter_clone.clone();
591                async move {
592                    let count = counter.fetch_add(1, Ordering::SeqCst);
593                    if count < 2 {
594                        Err(AptosError::Api {
595                            status_code: 503,
596                            message: "Service Unavailable".to_string(),
597                            error_code: None,
598                            vm_error_code: None,
599                        })
600                    } else {
601                        Ok(42)
602                    }
603                }
604            })
605            .await;
606
607        assert_eq!(result.unwrap(), 42);
608        assert_eq!(counter.load(Ordering::SeqCst), 3); // 2 failures + 1 success
609    }
610
611    #[tokio::test]
612    async fn test_retry_exhausted() {
613        let config = RetryConfig::builder()
614            .max_retries(2)
615            .initial_delay_ms(1)
616            .jitter(false)
617            .build();
618        let executor = RetryExecutor::new(config);
619        let counter = Arc::new(AtomicU32::new(0));
620        let counter_clone = counter.clone();
621
622        let result = executor
623            .execute(|| {
624                let counter = counter_clone.clone();
625                async move {
626                    counter.fetch_add(1, Ordering::SeqCst);
627                    Err::<i32, _>(AptosError::Api {
628                        status_code: 503,
629                        message: "Always fails".to_string(),
630                        error_code: None,
631                        vm_error_code: None,
632                    })
633                }
634            })
635            .await;
636
637        assert!(result.is_err());
638        assert_eq!(counter.load(Ordering::SeqCst), 3); // 1 initial + 2 retries
639    }
640
641    #[tokio::test]
642    async fn test_no_retry_on_non_retryable_error() {
643        let config = RetryConfig::builder()
644            .max_retries(3)
645            .initial_delay_ms(1)
646            .build();
647        let executor = RetryExecutor::new(config);
648        let counter = Arc::new(AtomicU32::new(0));
649        let counter_clone = counter.clone();
650
651        let result = executor
652            .execute(|| {
653                let counter = counter_clone.clone();
654                async move {
655                    counter.fetch_add(1, Ordering::SeqCst);
656                    Err::<i32, _>(AptosError::Api {
657                        status_code: 400, // Bad Request - not retryable
658                        message: "Bad Request".to_string(),
659                        error_code: None,
660                        vm_error_code: None,
661                    })
662                }
663            })
664            .await;
665
666        assert!(result.is_err());
667        assert_eq!(counter.load(Ordering::SeqCst), 1); // No retries
668    }
669
670    #[test]
671    fn test_aggressive_config() {
672        let config = RetryConfig::aggressive();
673        assert_eq!(config.max_retries, 5);
674        assert_eq!(config.initial_delay_ms, 50);
675        assert_eq!(config.max_delay_ms, 5_000);
676        assert!((config.exponential_base - 1.5).abs() < f64::EPSILON);
677        assert!(config.jitter);
678    }
679
680    #[test]
681    fn test_conservative_config() {
682        let config = RetryConfig::conservative();
683        assert_eq!(config.max_retries, 3);
684        assert_eq!(config.initial_delay_ms, 500);
685        assert_eq!(config.max_delay_ms, 30_000);
686        assert!((config.exponential_base - 2.0).abs() < f64::EPSILON);
687        assert!(config.jitter);
688    }
689
690    #[test]
691    fn test_builder_jitter_factor() {
692        let config = RetryConfig::builder().jitter_factor(0.25).build();
693
694        assert!((config.jitter_factor - 0.25).abs() < f64::EPSILON);
695    }
696
697    #[test]
698    fn test_builder_retryable_status_codes() {
699        let config = RetryConfig::builder()
700            .retryable_status_codes([500, 502])
701            .build();
702
703        assert!(config.is_retryable_status(500));
704        assert!(config.is_retryable_status(502));
705        assert!(!config.is_retryable_status(503)); // Not in our custom list
706    }
707
708    #[test]
709    fn test_delay_with_jitter() {
710        let config = RetryConfig::builder()
711            .initial_delay_ms(100)
712            .jitter(true)
713            .jitter_factor(0.5)
714            .build();
715
716        // With jitter, delays should vary
717        let delay1 = config.delay_for_attempt(1);
718        // Delay should be in range [50ms, 150ms] (100ms +/- 50%)
719        assert!(delay1 >= Duration::from_millis(50));
720        assert!(delay1 <= Duration::from_millis(150));
721    }
722
723    #[test]
724    fn test_delay_zero_for_first_attempt() {
725        let config = RetryConfig::default();
726        assert_eq!(config.delay_for_attempt(0), Duration::from_millis(0));
727    }
728
729    #[test]
730    fn test_retryable_error_transaction_error() {
731        let config = RetryConfig::default();
732
733        // Transaction errors are not retryable
734        let txn_error = AptosError::Transaction("failed".to_string());
735        assert!(!config.is_retryable_error(&txn_error));
736    }
737
738    #[test]
739    fn test_retryable_error_invalid_address() {
740        let config = RetryConfig::default();
741
742        // Invalid address errors are not retryable
743        let addr_error = AptosError::InvalidAddress("bad".to_string());
744        assert!(!config.is_retryable_error(&addr_error));
745    }
746
747    #[tokio::test]
748    async fn test_retry_with_no_retry_config() {
749        let config = RetryConfig::no_retry();
750        let executor = RetryExecutor::new(config);
751        let counter = Arc::new(AtomicU32::new(0));
752        let counter_clone = counter.clone();
753
754        let result = executor
755            .execute(|| {
756                let counter = counter_clone.clone();
757                async move {
758                    counter.fetch_add(1, Ordering::SeqCst);
759                    Err::<i32, _>(AptosError::Api {
760                        status_code: 503,
761                        message: "Service Unavailable".to_string(),
762                        error_code: None,
763                        vm_error_code: None,
764                    })
765                }
766            })
767            .await;
768
769        assert!(result.is_err());
770        assert_eq!(counter.load(Ordering::SeqCst), 1); // No retries with no_retry config
771    }
772
773    #[test]
774    fn test_retry_config_clone() {
775        let config = RetryConfig::builder()
776            .max_retries(5)
777            .initial_delay_ms(200)
778            .build();
779
780        let cloned = config.clone();
781        assert_eq!(config.max_retries, cloned.max_retries);
782        assert_eq!(config.initial_delay_ms, cloned.initial_delay_ms);
783    }
784
785    #[test]
786    fn test_retry_config_debug() {
787        let config = RetryConfig::default();
788        let debug = format!("{config:?}");
789        assert!(debug.contains("RetryConfig"));
790        assert!(debug.contains("max_retries"));
791    }
792
793    #[test]
794    fn test_builder_add_retryable_status_code() {
795        let config = RetryConfig::builder()
796            .add_retryable_status_code(599)
797            .build();
798
799        assert!(config.is_retryable_status(599));
800    }
801
802    #[test]
803    fn test_builder_add_duplicate_status_code() {
804        let config = RetryConfig::builder()
805            .add_retryable_status_code(500)
806            .add_retryable_status_code(500) // Duplicate
807            .build();
808
809        // HashSet automatically handles duplicates - 500 should be present
810        assert!(config.is_retryable_status(500));
811        // With HashSet, count will always be 1 for a present element
812        assert_eq!(config.retryable_status_codes.len(), 1);
813    }
814
815    #[test]
816    fn test_builder_jitter_factor_clamped() {
817        let config = RetryConfig::builder()
818            .jitter_factor(2.0) // Should be clamped to 1.0
819            .build();
820
821        assert!((config.jitter_factor - 1.0).abs() < f64::EPSILON);
822
823        let config2 = RetryConfig::builder()
824            .jitter_factor(-1.0) // Should be clamped to 0.0
825            .build();
826
827        assert!(config2.jitter_factor.abs() < f64::EPSILON);
828    }
829
830    #[test]
831    fn test_retry_executor_new() {
832        let config = RetryConfig::default();
833        let executor = RetryExecutor::new(config.clone());
834
835        let debug = format!("{executor:?}");
836        assert!(debug.contains("RetryExecutor"));
837    }
838
839    #[tokio::test]
840    async fn test_retry_with_custom_predicate() {
841        let config = RetryConfig::builder()
842            .max_retries(3)
843            .initial_delay_ms(1)
844            .jitter(false)
845            .build();
846        let executor = RetryExecutor::new(config);
847        let counter = Arc::new(AtomicU32::new(0));
848        let counter_clone = counter.clone();
849
850        // Custom predicate that always says "retry"
851        let result = executor
852            .execute_with_predicate(
853                || {
854                    let counter = counter_clone.clone();
855                    async move {
856                        let count = counter.fetch_add(1, Ordering::SeqCst);
857                        if count < 2 {
858                            Err(AptosError::NotFound("test".to_string()))
859                        } else {
860                            Ok(42)
861                        }
862                    }
863                },
864                |_| true, // Always retry
865            )
866            .await;
867
868        assert_eq!(result.unwrap(), 42);
869        assert_eq!(counter.load(Ordering::SeqCst), 3);
870    }
871
872    #[tokio::test]
873    async fn test_retry_with_predicate_no_retry() {
874        let config = RetryConfig::builder()
875            .max_retries(3)
876            .initial_delay_ms(1)
877            .build();
878        let executor = RetryExecutor::new(config);
879        let counter = Arc::new(AtomicU32::new(0));
880        let counter_clone = counter.clone();
881
882        // Custom predicate that never retries
883        let result = executor
884            .execute_with_predicate(
885                || {
886                    let counter = counter_clone.clone();
887                    async move {
888                        counter.fetch_add(1, Ordering::SeqCst);
889                        Err::<i32, _>(AptosError::Api {
890                            status_code: 503,
891                            message: "Fail".to_string(),
892                            error_code: None,
893                            vm_error_code: None,
894                        })
895                    }
896                },
897                |_| false, // Never retry
898            )
899            .await;
900
901        assert!(result.is_err());
902        assert_eq!(counter.load(Ordering::SeqCst), 1); // No retries
903    }
904
905    #[tokio::test]
906    async fn test_retry_convenience_function() {
907        let counter = Arc::new(AtomicU32::new(0));
908        let counter_clone = counter.clone();
909
910        let result = retry(|| {
911            let counter = counter_clone.clone();
912            async move {
913                counter.fetch_add(1, Ordering::SeqCst);
914                Ok::<_, AptosError>(42)
915            }
916        })
917        .await;
918
919        assert_eq!(result.unwrap(), 42);
920        assert_eq!(counter.load(Ordering::SeqCst), 1);
921    }
922
923    #[tokio::test]
924    async fn test_retry_with_config_convenience_function() {
925        let config = RetryConfig::builder()
926            .max_retries(1)
927            .initial_delay_ms(1)
928            .jitter(false)
929            .build();
930        let counter = Arc::new(AtomicU32::new(0));
931        let counter_clone = counter.clone();
932
933        let result = retry_with_config(&config, || {
934            let counter = counter_clone.clone();
935            async move {
936                let count = counter.fetch_add(1, Ordering::SeqCst);
937                if count < 1 {
938                    // Use a retryable API error instead of Http
939                    Err(AptosError::Api {
940                        status_code: 503,
941                        message: "Service Unavailable".to_string(),
942                        error_code: None,
943                        vm_error_code: None,
944                    })
945                } else {
946                    Ok(42)
947                }
948            }
949        })
950        .await;
951
952        assert_eq!(result.unwrap(), 42);
953        assert_eq!(counter.load(Ordering::SeqCst), 2);
954    }
955
956    #[test]
957    fn test_retryable_rate_limited_error() {
958        let config = RetryConfig::default();
959
960        // Test RateLimited which is always retryable
961        let rate_limited = AptosError::RateLimited {
962            retry_after_secs: Some(5),
963        };
964        assert!(config.is_retryable_error(&rate_limited));
965    }
966
967    #[test]
968    fn test_builder_default_debug() {
969        let builder = RetryConfigBuilder::default();
970        let debug = format!("{builder:?}");
971        assert!(debug.contains("RetryConfigBuilder"));
972    }
973
974    #[tokio::test]
975    async fn test_retry_ext_with_retry_succeeds_after_failures() {
976        let config = RetryConfig::builder()
977            .max_retries(3)
978            .initial_delay_ms(1)
979            .jitter(false)
980            .build();
981        let counter = Arc::new(AtomicU32::new(0));
982        let counter_clone = counter.clone();
983
984        // Exercise the `.with_retry(...)` extension method on an operation factory.
985        let result = (move || {
986            let counter = counter_clone.clone();
987            async move {
988                let count = counter.fetch_add(1, Ordering::SeqCst);
989                if count < 2 {
990                    Err(AptosError::Api {
991                        status_code: 503,
992                        message: "Service Unavailable".to_string(),
993                        error_code: None,
994                        vm_error_code: None,
995                    })
996                } else {
997                    Ok(42)
998                }
999            }
1000        })
1001        .with_retry(&config)
1002        .await;
1003
1004        assert_eq!(result.unwrap(), 42);
1005        assert_eq!(counter.load(Ordering::SeqCst), 3); // 2 failures + 1 success
1006    }
1007
1008    #[tokio::test]
1009    async fn test_retry_ext_with_retry_no_retry_on_non_retryable() {
1010        let config = RetryConfig::builder()
1011            .max_retries(3)
1012            .initial_delay_ms(1)
1013            .jitter(false)
1014            .build();
1015        let counter = Arc::new(AtomicU32::new(0));
1016        let counter_clone = counter.clone();
1017
1018        let result: AptosResult<i32> = (move || {
1019            let counter = counter_clone.clone();
1020            async move {
1021                counter.fetch_add(1, Ordering::SeqCst);
1022                Err(AptosError::Api {
1023                    status_code: 400, // Bad Request - not retryable
1024                    message: "Bad Request".to_string(),
1025                    error_code: None,
1026                    vm_error_code: None,
1027                })
1028            }
1029        })
1030        .with_retry(&config)
1031        .await;
1032
1033        assert!(result.is_err());
1034        assert_eq!(counter.load(Ordering::SeqCst), 1); // No retries
1035    }
1036}