Skip to main content

nautilus_network/
retry.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Generic retry mechanism for network operations.
17
18use std::{future::Future, marker::PhantomData, time::Duration};
19
20use serde::{Deserialize, Serialize};
21use tokio_util::sync::CancellationToken;
22
23use crate::{backoff::ExponentialBackoff, dst};
24
25/// Configuration for retry behavior.
26#[derive(Debug, Clone, Deserialize, Serialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct RetryConfig {
29    /// Maximum number of retry attempts (total attempts = 1 initial + `max_retries`).
30    pub max_retries: u32,
31    /// Initial delay between retries in milliseconds.
32    pub initial_delay_ms: u64,
33    /// Maximum delay between retries in milliseconds.
34    pub max_delay_ms: u64,
35    /// Backoff multiplier factor.
36    pub backoff_factor: f64,
37    /// Maximum jitter in milliseconds to add to delays.
38    pub jitter_ms: u64,
39    /// Optional timeout for individual operations in milliseconds.
40    /// If None, no timeout is applied.
41    pub operation_timeout_ms: Option<u64>,
42    /// Whether the first retry should happen immediately without delay.
43    /// Should be false for HTTP/order operations, true for connection operations.
44    pub immediate_first: bool,
45    /// Optional maximum total elapsed time for all retries in milliseconds.
46    /// If exceeded, retries stop even if `max_retries` hasn't been reached.
47    pub max_elapsed_ms: Option<u64>,
48}
49
50impl Default for RetryConfig {
51    fn default() -> Self {
52        Self {
53            max_retries: 3,
54            initial_delay_ms: 1_000,
55            max_delay_ms: 10_000,
56            backoff_factor: 2.0,
57            jitter_ms: 100,
58            operation_timeout_ms: Some(30_000),
59            immediate_first: false,
60            max_elapsed_ms: None,
61        }
62    }
63}
64
65/// Generic retry manager for network operations.
66///
67/// Stateless and thread-safe - each operation maintains its own backoff state.
68#[derive(Clone, Debug)]
69pub struct RetryManager<E> {
70    config: RetryConfig,
71    _phantom: PhantomData<E>,
72}
73
74impl<E> RetryManager<E>
75where
76    E: std::error::Error,
77{
78    /// Creates a new retry manager with the given configuration.
79    #[must_use]
80    pub const fn new(config: RetryConfig) -> Self {
81        Self {
82            config,
83            _phantom: PhantomData,
84        }
85    }
86
87    /// Formats a retry budget exceeded error message with attempt context.
88    #[inline(always)]
89    fn budget_exceeded_msg(&self, attempt: u32) -> String {
90        format!(
91            "Retry budget exceeded ({}/{})",
92            attempt.saturating_add(1),
93            self.config.max_retries.saturating_add(1)
94        )
95    }
96
97    /// Executes an operation with retry logic and optional cancellation.
98    ///
99    /// Cancellation is checked at three points:
100    /// (1) Before each operation attempt.
101    /// (2) During operation execution (via `tokio::select!`).
102    /// (3) During retry delays.
103    ///
104    /// Cancellation mid-execution takes effect immediately by dropping the in-flight
105    /// operation future. For non-idempotent operations (e.g. an order already on the
106    /// wire) the outcome of the abandoned attempt is unknown to the caller.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if the operation fails after exhausting all retries,
111    /// if the operation times out, if creating the backoff state fails, or if canceled.
112    pub async fn execute_with_retry_inner<F, Fut, T>(
113        &self,
114        operation_name: &str,
115        mut operation: F,
116        should_retry: impl Fn(&E) -> bool,
117        create_error: impl Fn(String) -> E,
118        cancel: Option<&CancellationToken>,
119    ) -> Result<T, E>
120    where
121        F: FnMut() -> Fut,
122        Fut: Future<Output = Result<T, E>>,
123    {
124        let mut backoff = ExponentialBackoff::new(
125            Duration::from_millis(self.config.initial_delay_ms),
126            Duration::from_millis(self.config.max_delay_ms),
127            self.config.backoff_factor,
128            self.config.jitter_ms,
129            self.config.immediate_first,
130        )
131        .map_err(|e| create_error(format!("Invalid configuration: {e}")))?;
132
133        let mut attempt = 0;
134        let start_time = dst::time::Instant::now();
135
136        loop {
137            if let Some(token) = cancel
138                && token.is_cancelled()
139            {
140                log::debug!("Operation '{operation_name}' canceled after {attempt} attempts");
141                return Err(create_error("canceled".to_string()));
142            }
143
144            if let Some(max_elapsed_ms) = self.config.max_elapsed_ms {
145                let elapsed = start_time.elapsed();
146                if elapsed.as_millis() >= u128::from(max_elapsed_ms) {
147                    return Err(create_error(self.budget_exceeded_msg(attempt)));
148                }
149            }
150
151            let result = match (self.config.operation_timeout_ms, cancel) {
152                (Some(timeout_ms), Some(token)) => {
153                    tokio::select! {
154                        biased;
155                        result = dst::time::timeout(Duration::from_millis(timeout_ms), operation()) => result,
156                        () = token.cancelled() => {
157                            log::debug!("Operation '{operation_name}' canceled during execution");
158                            return Err(create_error("canceled".to_string()));
159                        }
160                    }
161                }
162                (Some(timeout_ms), None) => {
163                    dst::time::timeout(Duration::from_millis(timeout_ms), operation()).await
164                }
165                (None, Some(token)) => {
166                    tokio::select! {
167                        biased;
168                        result = operation() => Ok(result),
169                        () = token.cancelled() => {
170                            log::debug!("Operation '{operation_name}' canceled during execution");
171                            return Err(create_error("canceled".to_string()));
172                        }
173                    }
174                }
175                (None, None) => Ok(operation().await),
176            };
177
178            match result {
179                Ok(Ok(success)) => {
180                    if attempt > 0 {
181                        log::trace!(
182                            "Operation '{operation_name}' succeeded after {} attempts",
183                            attempt + 1
184                        );
185                    }
186                    return Ok(success);
187                }
188                Ok(Err(e)) => {
189                    if !should_retry(&e) {
190                        log::trace!("Operation '{operation_name}' non-retryable error: {e}");
191                        return Err(e);
192                    }
193
194                    if attempt >= self.config.max_retries {
195                        log::trace!(
196                            "Operation '{operation_name}' retries exhausted after {} attempts: {e}",
197                            attempt + 1
198                        );
199                        return Err(e);
200                    }
201
202                    let mut delay = backoff.next_duration();
203
204                    if let Some(max_elapsed_ms) = self.config.max_elapsed_ms {
205                        let elapsed = start_time.elapsed();
206                        let remaining =
207                            Duration::from_millis(max_elapsed_ms).saturating_sub(elapsed);
208
209                        if remaining.is_zero() {
210                            return Err(create_error(format!(
211                                "{}: last error: {e}",
212                                self.budget_exceeded_msg(attempt)
213                            )));
214                        }
215
216                        delay = delay.min(remaining);
217                    }
218
219                    log::trace!(
220                        "Operation '{operation_name}' attempt {} failed, retrying in {}ms: {e}",
221                        attempt + 1,
222                        delay.as_millis()
223                    );
224
225                    // Yield even on zero-delay to avoid busy-wait loop
226                    if delay.is_zero() {
227                        tokio::task::yield_now().await;
228                        attempt += 1;
229                        continue;
230                    }
231
232                    if let Some(token) = cancel {
233                        tokio::select! {
234                            biased;
235                            () = dst::time::sleep(delay) => {},
236                            () = token.cancelled() => {
237                                log::debug!("Operation '{operation_name}' canceled during retry delay (attempt {})", attempt + 1);
238                                return Err(create_error("canceled".to_string()));
239                            }
240                        }
241                    } else {
242                        dst::time::sleep(delay).await;
243                    }
244                    attempt += 1;
245                }
246                Err(_) => {
247                    let e = create_error(format!(
248                        "Timed out after {}ms",
249                        self.config.operation_timeout_ms.unwrap_or(0)
250                    ));
251
252                    if !should_retry(&e) {
253                        log::trace!("Operation '{operation_name}' non-retryable timeout: {e}");
254                        return Err(e);
255                    }
256
257                    if attempt >= self.config.max_retries {
258                        log::trace!(
259                            "Operation '{operation_name}' retries exhausted after timeout ({} attempts): {e}",
260                            attempt + 1
261                        );
262                        return Err(e);
263                    }
264
265                    let mut delay = backoff.next_duration();
266
267                    if let Some(max_elapsed_ms) = self.config.max_elapsed_ms {
268                        let elapsed = start_time.elapsed();
269                        let remaining =
270                            Duration::from_millis(max_elapsed_ms).saturating_sub(elapsed);
271
272                        if remaining.is_zero() {
273                            return Err(create_error(format!(
274                                "{}: last error: {e}",
275                                self.budget_exceeded_msg(attempt)
276                            )));
277                        }
278
279                        delay = delay.min(remaining);
280                    }
281
282                    log::trace!(
283                        "Operation '{operation_name}' attempt {} timed out, retrying in {}ms: {e}",
284                        attempt + 1,
285                        delay.as_millis()
286                    );
287
288                    // Yield even on zero-delay to avoid busy-wait loop
289                    if delay.is_zero() {
290                        tokio::task::yield_now().await;
291                        attempt += 1;
292                        continue;
293                    }
294
295                    if let Some(token) = cancel {
296                        tokio::select! {
297                            biased;
298                            () = dst::time::sleep(delay) => {},
299                            () = token.cancelled() => {
300                                log::debug!("Operation '{operation_name}' canceled during retry delay (attempt {})", attempt + 1);
301                                return Err(create_error("canceled".to_string()));
302                            }
303                        }
304                    } else {
305                        dst::time::sleep(delay).await;
306                    }
307                    attempt += 1;
308                }
309            }
310        }
311    }
312
313    /// Executes an operation with retry logic.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if the operation fails after exhausting all retries,
318    /// if the operation times out, or if creating the backoff state fails.
319    pub async fn execute_with_retry<F, Fut, T>(
320        &self,
321        operation_name: &str,
322        operation: F,
323        should_retry: impl Fn(&E) -> bool,
324        create_error: impl Fn(String) -> E,
325    ) -> Result<T, E>
326    where
327        F: FnMut() -> Fut,
328        Fut: Future<Output = Result<T, E>>,
329    {
330        self.execute_with_retry_inner(operation_name, operation, should_retry, create_error, None)
331            .await
332    }
333
334    /// Executes an operation with retry logic and cancellation support.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if the operation fails after exhausting all retries,
339    /// if the operation times out, if creating the backoff state fails, or if canceled.
340    pub async fn execute_with_retry_with_cancel<F, Fut, T>(
341        &self,
342        operation_name: &str,
343        operation: F,
344        should_retry: impl Fn(&E) -> bool,
345        create_error: impl Fn(String) -> E,
346        cancellation_token: &CancellationToken,
347    ) -> Result<T, E>
348    where
349        F: FnMut() -> Fut,
350        Fut: Future<Output = Result<T, E>>,
351    {
352        self.execute_with_retry_inner(
353            operation_name,
354            operation,
355            should_retry,
356            create_error,
357            Some(cancellation_token),
358        )
359        .await
360    }
361}
362
363/// Convenience function to create a retry manager with default configuration.
364#[must_use]
365pub fn create_default_retry_manager<E>() -> RetryManager<E>
366where
367    E: std::error::Error,
368{
369    RetryManager::new(RetryConfig::default())
370}
371
372/// Convenience function to create a retry manager for HTTP operations.
373#[must_use]
374pub const fn create_http_retry_manager<E>() -> RetryManager<E>
375where
376    E: std::error::Error,
377{
378    let config = RetryConfig {
379        max_retries: 3,
380        initial_delay_ms: 1_000,
381        max_delay_ms: 10_000,
382        backoff_factor: 2.0,
383        jitter_ms: 1_000,
384        operation_timeout_ms: Some(60_000), // 60s for HTTP requests
385        immediate_first: false,
386        max_elapsed_ms: Some(180_000), // 3 minutes total budget
387    };
388    RetryManager::new(config)
389}
390
391/// Convenience function to create a retry manager for WebSocket operations.
392#[must_use]
393pub const fn create_websocket_retry_manager<E>() -> RetryManager<E>
394where
395    E: std::error::Error,
396{
397    let config = RetryConfig {
398        max_retries: 5,
399        initial_delay_ms: 1_000,
400        max_delay_ms: 10_000,
401        backoff_factor: 2.0,
402        jitter_ms: 1_000,
403        operation_timeout_ms: Some(30_000), // 30s for WebSocket operations
404        immediate_first: true,
405        max_elapsed_ms: Some(120_000), // 2 minutes total budget
406    };
407    RetryManager::new(config)
408}
409
410#[cfg(test)]
411mod test_utils {
412    #[derive(Debug, thiserror::Error)]
413    pub(super) enum TestError {
414        #[error("Retryable error: {0}")]
415        Retryable(String),
416        #[error("Non-retryable error: {0}")]
417        NonRetryable(String),
418        #[error("Timeout error: {0}")]
419        Timeout(String),
420    }
421
422    pub(super) fn should_retry_test_error(error: &TestError) -> bool {
423        matches!(error, TestError::Retryable(_))
424    }
425
426    pub(super) fn create_test_error(msg: String) -> TestError {
427        TestError::Timeout(msg)
428    }
429}
430
431// Retry tests run under both real tokio (`#[tokio::test]`, paused-clock when
432// the test relies on virtual time advance) and madsim (`#[madsim::test]`,
433// virtual time always paused). `tokio::time::advance` has no direct madsim
434// equivalent, so explicit clock advances route through `advance_clock` below;
435// time reads and sleeps go through the `dst::time` re-export so they pick up
436// the runtime-appropriate clock. madsim auto-advances virtual time when all
437// tasks block, but `yield_until`-style busy-yield loops keep the runtime
438// non-idle, so explicit advances are still needed where they were before.
439#[cfg(test)]
440mod tests {
441    use std::sync::{
442        Arc,
443        atomic::{AtomicU32, Ordering},
444    };
445
446    #[cfg(all(feature = "simulation", madsim))]
447    use madsim::task::{spawn, yield_now};
448    use nautilus_core::MUTEX_POISONED;
449    use rstest::rstest;
450    #[cfg(not(all(feature = "simulation", madsim)))]
451    use tokio::task::{spawn, yield_now};
452
453    use super::{test_utils::*, *};
454    use crate::dst::time;
455
456    const MAX_WAIT_ITERS: usize = 10_000;
457    const MAX_ADVANCE_ITERS: usize = 10_000;
458
459    #[cfg(all(feature = "simulation", madsim))]
460    pub(crate) async fn advance_clock(d: Duration) {
461        madsim::time::advance(d);
462        madsim::task::yield_now().await;
463    }
464
465    #[cfg(not(all(feature = "simulation", madsim)))]
466    pub(crate) async fn advance_clock(d: Duration) {
467        tokio::time::advance(d).await;
468    }
469
470    pub(crate) async fn yield_until<F>(mut condition: F)
471    where
472        F: FnMut() -> bool,
473    {
474        for _ in 0..MAX_WAIT_ITERS {
475            if condition() {
476                return;
477            }
478            yield_now().await;
479        }
480
481        panic!("yield_until timed out waiting for condition");
482    }
483
484    pub(crate) async fn advance_until<F>(mut condition: F)
485    where
486        F: FnMut() -> bool,
487    {
488        for _ in 0..MAX_ADVANCE_ITERS {
489            if condition() {
490                return;
491            }
492            advance_clock(Duration::from_millis(1)).await;
493            yield_now().await;
494        }
495
496        panic!("advance_until timed out waiting for condition");
497    }
498
499    #[rstest]
500    fn test_retry_config_default() {
501        let config = RetryConfig::default();
502        assert_eq!(config.max_retries, 3);
503        assert_eq!(config.initial_delay_ms, 1_000);
504        assert_eq!(config.max_delay_ms, 10_000);
505        #[expect(clippy::float_cmp, reason = "test asserts the default backoff factor")]
506        {
507            assert_eq!(config.backoff_factor, 2.0);
508        }
509        assert_eq!(config.jitter_ms, 100);
510        assert_eq!(config.operation_timeout_ms, Some(30_000));
511        assert!(!config.immediate_first);
512        assert_eq!(config.max_elapsed_ms, None);
513    }
514
515    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
516    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
517    async fn test_retry_manager_success_first_attempt() {
518        let manager = RetryManager::new(RetryConfig::default());
519
520        let result = manager
521            .execute_with_retry(
522                "test_operation",
523                || async { Ok::<i32, TestError>(42) },
524                should_retry_test_error,
525                create_test_error,
526            )
527            .await;
528
529        assert_eq!(result.unwrap(), 42);
530    }
531
532    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
533    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
534    async fn test_retry_manager_non_retryable_error() {
535        let manager = RetryManager::new(RetryConfig::default());
536
537        let result = manager
538            .execute_with_retry(
539                "test_operation",
540                || async { Err::<i32, TestError>(TestError::NonRetryable("test".to_string())) },
541                should_retry_test_error,
542                create_test_error,
543            )
544            .await;
545
546        assert!(result.is_err());
547        assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
548    }
549
550    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
551    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
552    async fn test_retry_manager_retryable_error_exhausted() {
553        let config = RetryConfig {
554            max_retries: 2,
555            initial_delay_ms: 10,
556            max_delay_ms: 50,
557            backoff_factor: 2.0,
558            jitter_ms: 0,
559            operation_timeout_ms: None,
560            immediate_first: false,
561            max_elapsed_ms: None,
562        };
563        let manager = RetryManager::new(config);
564
565        let result = manager
566            .execute_with_retry(
567                "test_operation",
568                || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
569                should_retry_test_error,
570                create_test_error,
571            )
572            .await;
573
574        assert!(result.is_err());
575        assert!(matches!(result.unwrap_err(), TestError::Retryable(_)));
576    }
577
578    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
579    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
580    async fn test_timeout_path() {
581        let config = RetryConfig {
582            max_retries: 2,
583            initial_delay_ms: 10,
584            max_delay_ms: 50,
585            backoff_factor: 2.0,
586            jitter_ms: 0,
587            operation_timeout_ms: Some(50),
588            immediate_first: false,
589            max_elapsed_ms: None,
590        };
591        let manager = RetryManager::new(config);
592
593        let result = manager
594            .execute_with_retry(
595                "test_timeout",
596                || async {
597                    time::sleep(Duration::from_millis(100)).await;
598                    Ok::<i32, TestError>(42)
599                },
600                should_retry_test_error,
601                create_test_error,
602            )
603            .await;
604
605        assert!(result.is_err());
606        assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
607    }
608
609    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
610    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
611    async fn test_max_elapsed_time_budget() {
612        let config = RetryConfig {
613            max_retries: 10,
614            initial_delay_ms: 50,
615            max_delay_ms: 100,
616            backoff_factor: 2.0,
617            jitter_ms: 0,
618            operation_timeout_ms: None,
619            immediate_first: false,
620            max_elapsed_ms: Some(200),
621        };
622        let manager = RetryManager::new(config);
623
624        let start = time::Instant::now();
625        let result = manager
626            .execute_with_retry(
627                "test_budget",
628                || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
629                should_retry_test_error,
630                create_test_error,
631            )
632            .await;
633
634        let elapsed = start.elapsed();
635        assert!(result.is_err());
636        assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
637        assert!(elapsed.as_millis() >= 150);
638        assert!(elapsed.as_millis() < 1000);
639    }
640
641    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
642    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
643    async fn test_budget_exceeded_message_format() {
644        let config = RetryConfig {
645            max_retries: 5,
646            initial_delay_ms: 10,
647            max_delay_ms: 20,
648            backoff_factor: 1.0,
649            jitter_ms: 0,
650            operation_timeout_ms: None,
651            immediate_first: false,
652            max_elapsed_ms: Some(35),
653        };
654        let manager = RetryManager::new(config);
655
656        let result = manager
657            .execute_with_retry(
658                "test_budget_msg",
659                || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
660                should_retry_test_error,
661                create_test_error,
662            )
663            .await;
664
665        assert!(result.is_err());
666        let error_msg = result.unwrap_err().to_string();
667
668        assert!(error_msg.contains("Retry budget exceeded"));
669        assert!(error_msg.contains("/6)"));
670
671        if let Some(captures) = error_msg.strip_prefix("Timeout error: Retry budget exceeded (")
672            && let Some(nums) = captures.strip_suffix(")")
673        {
674            let parts: Vec<&str> = nums.split('/').collect();
675            assert_eq!(parts.len(), 2);
676            let current: u32 = parts[0].parse().unwrap();
677            let total: u32 = parts[1].parse().unwrap();
678
679            assert_eq!(total, 6, "Total should be max_retries + 1");
680            assert!(current <= total, "Current attempt should not exceed total");
681            assert!(current >= 1, "Current attempt should be at least 1");
682        }
683    }
684
685    #[cfg_attr(
686        not(all(feature = "simulation", madsim)),
687        tokio::test(start_paused = true)
688    )]
689    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
690    async fn test_budget_exceeded_edge_cases() {
691        let config = RetryConfig {
692            max_retries: 2,
693            initial_delay_ms: 50,
694            max_delay_ms: 100,
695            backoff_factor: 1.0,
696            jitter_ms: 0,
697            operation_timeout_ms: None,
698            immediate_first: false,
699            max_elapsed_ms: Some(100),
700        };
701        let manager = RetryManager::new(config);
702
703        let attempt_count = Arc::new(AtomicU32::new(0));
704        let count_clone = attempt_count.clone();
705
706        let handle = spawn(async move {
707            manager
708                .execute_with_retry(
709                    "test_first_attempt",
710                    move || {
711                        let count = count_clone.clone();
712                        async move {
713                            count.fetch_add(1, Ordering::SeqCst);
714                            Err::<i32, TestError>(TestError::Retryable("test".to_string()))
715                        }
716                    },
717                    should_retry_test_error,
718                    create_test_error,
719                )
720                .await
721        });
722
723        // Wait for first attempt
724        yield_until(|| attempt_count.load(Ordering::SeqCst) >= 1).await;
725
726        // Advance past budget to trigger check at loop start before second attempt
727        advance_clock(Duration::from_millis(101)).await;
728        yield_now().await;
729
730        let result = handle.await.unwrap();
731        assert!(result.is_err());
732        let error_msg = result.unwrap_err().to_string();
733
734        // Budget check happens at loop start, so shows (2/3) = "starting 2nd of 3 attempts"
735        assert!(
736            error_msg.contains("(2/3)"),
737            "Expected (2/3) but got: {error_msg}"
738        );
739    }
740
741    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
742    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
743    async fn test_budget_exceeded_no_overflow() {
744        let config = RetryConfig {
745            max_retries: u32::MAX,
746            initial_delay_ms: 10,
747            max_delay_ms: 20,
748            backoff_factor: 1.0,
749            jitter_ms: 0,
750            operation_timeout_ms: None,
751            immediate_first: false,
752            max_elapsed_ms: Some(1),
753        };
754        let manager = RetryManager::new(config);
755
756        let result = manager
757            .execute_with_retry(
758                "test_overflow",
759                || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
760                should_retry_test_error,
761                create_test_error,
762            )
763            .await;
764
765        assert!(result.is_err());
766        let error_msg = result.unwrap_err().to_string();
767
768        // Should saturate at u32::MAX instead of wrapping to 0
769        assert!(error_msg.contains("Retry budget exceeded"));
770        assert!(error_msg.contains(&format!("/{}", u32::MAX)));
771    }
772
773    #[rstest]
774    fn test_http_retry_manager_config() {
775        let manager = create_http_retry_manager::<TestError>();
776        assert_eq!(manager.config.max_retries, 3);
777        assert!(!manager.config.immediate_first);
778        assert_eq!(manager.config.max_elapsed_ms, Some(180_000));
779    }
780
781    #[rstest]
782    fn test_websocket_retry_manager_config() {
783        let manager = create_websocket_retry_manager::<TestError>();
784        assert_eq!(manager.config.max_retries, 5);
785        assert!(manager.config.immediate_first);
786        assert_eq!(manager.config.max_elapsed_ms, Some(120_000));
787    }
788
789    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
790    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
791    async fn test_timeout_respects_retry_predicate() {
792        let config = RetryConfig {
793            max_retries: 3,
794            initial_delay_ms: 10,
795            max_delay_ms: 50,
796            backoff_factor: 2.0,
797            jitter_ms: 0,
798            operation_timeout_ms: Some(50),
799            immediate_first: false,
800            max_elapsed_ms: None,
801        };
802        let manager = RetryManager::new(config);
803
804        // Test with retry predicate that rejects timeouts
805        let should_not_retry_timeouts = |error: &TestError| !matches!(error, TestError::Timeout(_));
806
807        let result = manager
808            .execute_with_retry(
809                "test_timeout_non_retryable",
810                || async {
811                    time::sleep(Duration::from_millis(100)).await;
812                    Ok::<i32, TestError>(42)
813                },
814                should_not_retry_timeouts,
815                create_test_error,
816            )
817            .await;
818
819        // Should fail immediately without retries since timeout is non-retryable
820        assert!(result.is_err());
821        assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
822    }
823
824    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
825    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
826    async fn test_timeout_retries_when_predicate_allows() {
827        let config = RetryConfig {
828            max_retries: 2,
829            initial_delay_ms: 10,
830            max_delay_ms: 50,
831            backoff_factor: 2.0,
832            jitter_ms: 0,
833            operation_timeout_ms: Some(50),
834            immediate_first: false,
835            max_elapsed_ms: None,
836        };
837        let manager = RetryManager::new(config);
838
839        // Test with retry predicate that allows timeouts
840        let should_retry_timeouts = |error: &TestError| matches!(error, TestError::Timeout(_));
841
842        let start = time::Instant::now();
843        let result = manager
844            .execute_with_retry(
845                "test_timeout_retryable",
846                || async {
847                    time::sleep(Duration::from_millis(100)).await;
848                    Ok::<i32, TestError>(42)
849                },
850                should_retry_timeouts,
851                create_test_error,
852            )
853            .await;
854
855        let elapsed = start.elapsed();
856
857        // Should fail after retries (not immediately)
858        assert!(result.is_err());
859        assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
860        // Should have taken time for retries (at least 2 timeouts + delays)
861        assert!(elapsed.as_millis() > 80); // More than just one timeout
862    }
863
864    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
865    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
866    async fn test_successful_retry_after_failures() {
867        let config = RetryConfig {
868            max_retries: 3,
869            initial_delay_ms: 10,
870            max_delay_ms: 50,
871            backoff_factor: 2.0,
872            jitter_ms: 0,
873            operation_timeout_ms: None,
874            immediate_first: false,
875            max_elapsed_ms: None,
876        };
877        let manager = RetryManager::new(config);
878
879        let attempt_counter = Arc::new(AtomicU32::new(0));
880        let counter_clone = attempt_counter.clone();
881
882        let result = manager
883            .execute_with_retry(
884                "test_eventual_success",
885                move || {
886                    let counter = counter_clone.clone();
887                    async move {
888                        let attempts = counter.fetch_add(1, Ordering::SeqCst);
889                        if attempts < 2 {
890                            Err(TestError::Retryable("temporary failure".to_string()))
891                        } else {
892                            Ok(42)
893                        }
894                    }
895                },
896                should_retry_test_error,
897                create_test_error,
898            )
899            .await;
900
901        assert_eq!(result.unwrap(), 42);
902        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
903    }
904
905    #[cfg_attr(
906        not(all(feature = "simulation", madsim)),
907        tokio::test(start_paused = true)
908    )]
909    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
910    async fn test_immediate_first_retry() {
911        let config = RetryConfig {
912            max_retries: 2,
913            initial_delay_ms: 100,
914            max_delay_ms: 200,
915            backoff_factor: 2.0,
916            jitter_ms: 0,
917            operation_timeout_ms: None,
918            immediate_first: true,
919            max_elapsed_ms: None,
920        };
921        let manager = RetryManager::new(config);
922
923        let attempt_times = Arc::new(std::sync::Mutex::new(Vec::new()));
924        let times_clone = attempt_times.clone();
925        let start = time::Instant::now();
926
927        let handle = spawn({
928            let times_clone = times_clone.clone();
929            async move {
930                let _ = manager
931                    .execute_with_retry(
932                        "test_immediate",
933                        move || {
934                            let times = times_clone.clone();
935                            async move {
936                                times.lock().expect(MUTEX_POISONED).push(start.elapsed());
937                                Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
938                            }
939                        },
940                        should_retry_test_error,
941                        create_test_error,
942                    )
943                    .await;
944            }
945        });
946
947        // Allow initial attempt and immediate retry to run without advancing time
948        yield_until(|| attempt_times.lock().expect(MUTEX_POISONED).len() >= 2).await;
949
950        // Advance time for the next backoff interval
951        advance_clock(Duration::from_millis(100)).await;
952        yield_now().await;
953
954        // Wait for the final retry to be recorded
955        yield_until(|| attempt_times.lock().expect(MUTEX_POISONED).len() >= 3).await;
956
957        handle.await.unwrap();
958
959        let times = attempt_times.lock().expect(MUTEX_POISONED);
960        assert_eq!(times.len(), 3); // Initial + 2 retries
961
962        // First retry should be immediate (within 1ms tolerance)
963        assert!(times[1] <= Duration::from_millis(1));
964        // Second retry should have backoff delay (at least 100ms from start)
965        assert!(times[2] >= Duration::from_millis(100));
966        assert!(times[2] <= Duration::from_millis(110));
967    }
968
969    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
970    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
971    async fn test_operation_without_timeout() {
972        let config = RetryConfig {
973            max_retries: 2,
974            initial_delay_ms: 10,
975            max_delay_ms: 50,
976            backoff_factor: 2.0,
977            jitter_ms: 0,
978            operation_timeout_ms: None, // No timeout
979            immediate_first: false,
980            max_elapsed_ms: None,
981        };
982        let manager = RetryManager::new(config);
983
984        let start = time::Instant::now();
985        let result = manager
986            .execute_with_retry(
987                "test_no_timeout",
988                || async {
989                    time::sleep(Duration::from_millis(50)).await;
990                    Ok::<i32, TestError>(42)
991                },
992                should_retry_test_error,
993                create_test_error,
994            )
995            .await;
996
997        let elapsed = start.elapsed();
998        assert_eq!(result.unwrap(), 42);
999        // Should complete without timing out
1000        assert!(elapsed.as_millis() >= 30);
1001        assert!(elapsed.as_millis() < 200);
1002    }
1003
1004    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1005    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1006    async fn test_zero_retries() {
1007        let config = RetryConfig {
1008            max_retries: 0,
1009            initial_delay_ms: 10,
1010            max_delay_ms: 50,
1011            backoff_factor: 2.0,
1012            jitter_ms: 0,
1013            operation_timeout_ms: None,
1014            immediate_first: false,
1015            max_elapsed_ms: None,
1016        };
1017        let manager = RetryManager::new(config);
1018
1019        let attempt_counter = Arc::new(AtomicU32::new(0));
1020        let counter_clone = attempt_counter.clone();
1021
1022        let result = manager
1023            .execute_with_retry(
1024                "test_no_retries",
1025                move || {
1026                    let counter = counter_clone.clone();
1027                    async move {
1028                        counter.fetch_add(1, Ordering::SeqCst);
1029                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1030                    }
1031                },
1032                should_retry_test_error,
1033                create_test_error,
1034            )
1035            .await;
1036
1037        assert!(result.is_err());
1038        // Should only attempt once (no retries)
1039        assert_eq!(attempt_counter.load(Ordering::SeqCst), 1);
1040    }
1041
1042    #[cfg_attr(
1043        not(all(feature = "simulation", madsim)),
1044        tokio::test(start_paused = true)
1045    )]
1046    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1047    async fn test_jitter_applied() {
1048        let config = RetryConfig {
1049            max_retries: 2,
1050            initial_delay_ms: 50,
1051            max_delay_ms: 100,
1052            backoff_factor: 2.0,
1053            jitter_ms: 50, // Significant jitter
1054            operation_timeout_ms: None,
1055            immediate_first: false,
1056            max_elapsed_ms: None,
1057        };
1058        let manager = RetryManager::new(config);
1059
1060        let delays = Arc::new(std::sync::Mutex::new(Vec::new()));
1061        let delays_clone = delays.clone();
1062        let last_time = Arc::new(std::sync::Mutex::new(time::Instant::now()));
1063        let last_time_clone = last_time.clone();
1064
1065        let handle = spawn({
1066            let delays_clone = delays_clone.clone();
1067            async move {
1068                let _ = manager
1069                    .execute_with_retry(
1070                        "test_jitter",
1071                        move || {
1072                            let delays = delays_clone.clone();
1073                            let last_time = last_time_clone.clone();
1074                            async move {
1075                                let now = time::Instant::now();
1076                                let delay = {
1077                                    let mut last = last_time.lock().expect(MUTEX_POISONED);
1078                                    let d = now.duration_since(*last);
1079                                    *last = now;
1080                                    d
1081                                };
1082                                delays.lock().expect(MUTEX_POISONED).push(delay);
1083                                Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1084                            }
1085                        },
1086                        should_retry_test_error,
1087                        create_test_error,
1088                    )
1089                    .await;
1090            }
1091        });
1092
1093        yield_until(|| !delays.lock().expect(MUTEX_POISONED).is_empty()).await;
1094        advance_until(|| delays.lock().expect(MUTEX_POISONED).len() >= 2).await;
1095        advance_until(|| delays.lock().expect(MUTEX_POISONED).len() >= 3).await;
1096
1097        handle.await.unwrap();
1098
1099        let delays = delays.lock().expect(MUTEX_POISONED);
1100        // Skip the first delay (initial attempt)
1101        for delay in delays.iter().skip(1) {
1102            // Each delay should be at least the base delay (50ms for first retry)
1103            assert!(delay.as_millis() >= 50);
1104            // But no more than base + jitter (allow small tolerance for step advance)
1105            assert!(delay.as_millis() <= 151);
1106        }
1107    }
1108
1109    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1110    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1111    async fn test_max_elapsed_stops_early() {
1112        let config = RetryConfig {
1113            max_retries: 100, // Very high retry count
1114            initial_delay_ms: 50,
1115            max_delay_ms: 100,
1116            backoff_factor: 1.5,
1117            jitter_ms: 0,
1118            operation_timeout_ms: None,
1119            immediate_first: false,
1120            max_elapsed_ms: Some(150), // Should stop after ~3 attempts
1121        };
1122        let manager = RetryManager::new(config);
1123
1124        let attempt_counter = Arc::new(AtomicU32::new(0));
1125        let counter_clone = attempt_counter.clone();
1126
1127        let start = time::Instant::now();
1128        let result = manager
1129            .execute_with_retry(
1130                "test_elapsed_limit",
1131                move || {
1132                    let counter = counter_clone.clone();
1133                    async move {
1134                        counter.fetch_add(1, Ordering::SeqCst);
1135                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1136                    }
1137                },
1138                should_retry_test_error,
1139                create_test_error,
1140            )
1141            .await;
1142
1143        let elapsed = start.elapsed();
1144        assert!(result.is_err());
1145        assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1146
1147        // Should have stopped due to time limit, not retry count
1148        let attempts = attempt_counter.load(Ordering::SeqCst);
1149        assert!(attempts < 10); // Much less than max_retries
1150        assert!(elapsed.as_millis() >= 100);
1151    }
1152
1153    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1154    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1155    async fn test_mixed_errors_retry_behavior() {
1156        let config = RetryConfig {
1157            max_retries: 5,
1158            initial_delay_ms: 10,
1159            max_delay_ms: 50,
1160            backoff_factor: 2.0,
1161            jitter_ms: 0,
1162            operation_timeout_ms: None,
1163            immediate_first: false,
1164            max_elapsed_ms: None,
1165        };
1166        let manager = RetryManager::new(config);
1167
1168        let attempt_counter = Arc::new(AtomicU32::new(0));
1169        let counter_clone = attempt_counter.clone();
1170
1171        let result = manager
1172            .execute_with_retry(
1173                "test_mixed_errors",
1174                move || {
1175                    let counter = counter_clone.clone();
1176                    async move {
1177                        let attempts = counter.fetch_add(1, Ordering::SeqCst);
1178                        match attempts {
1179                            0 => Err(TestError::Retryable("retry 1".to_string())),
1180                            1 => Err(TestError::Retryable("retry 2".to_string())),
1181                            2 => Err(TestError::NonRetryable("stop here".to_string())),
1182                            _ => Ok(42),
1183                        }
1184                    }
1185                },
1186                should_retry_test_error,
1187                create_test_error,
1188            )
1189            .await;
1190
1191        assert!(result.is_err());
1192        assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
1193        // Should stop at the non-retryable error
1194        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1195    }
1196
1197    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1198    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1199    async fn test_cancellation_during_retry_delay() {
1200        use tokio_util::sync::CancellationToken;
1201
1202        let config = RetryConfig {
1203            max_retries: 10,
1204            initial_delay_ms: 500, // Long delay to ensure cancellation happens during sleep
1205            max_delay_ms: 1000,
1206            backoff_factor: 2.0,
1207            jitter_ms: 0,
1208            operation_timeout_ms: None,
1209            immediate_first: false,
1210            max_elapsed_ms: None,
1211        };
1212        let manager = RetryManager::new(config);
1213
1214        let token = CancellationToken::new();
1215        let token_clone = token.clone();
1216
1217        // Cancel after a short delay
1218        spawn(async move {
1219            time::sleep(Duration::from_millis(100)).await;
1220            token_clone.cancel();
1221        });
1222
1223        let attempt_counter = Arc::new(AtomicU32::new(0));
1224        let counter_clone = attempt_counter.clone();
1225
1226        let start = time::Instant::now();
1227        let result = manager
1228            .execute_with_retry_with_cancel(
1229                "test_cancellation",
1230                move || {
1231                    let counter = counter_clone.clone();
1232                    async move {
1233                        counter.fetch_add(1, Ordering::SeqCst);
1234                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1235                    }
1236                },
1237                should_retry_test_error,
1238                create_test_error,
1239                &token,
1240            )
1241            .await;
1242
1243        let elapsed = start.elapsed();
1244
1245        // Should be canceled quickly
1246        assert!(result.is_err());
1247        let error_msg = format!("{}", result.unwrap_err());
1248        assert!(error_msg.contains("canceled"));
1249
1250        // Should not have taken the full delay time
1251        assert!(elapsed.as_millis() < 600);
1252
1253        // Should have made at least one attempt
1254        let attempts = attempt_counter.load(Ordering::SeqCst);
1255        assert!(attempts >= 1);
1256    }
1257
1258    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1259    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1260    async fn test_cancellation_during_operation_execution() {
1261        use tokio_util::sync::CancellationToken;
1262
1263        let config = RetryConfig {
1264            max_retries: 5,
1265            initial_delay_ms: 50,
1266            max_delay_ms: 100,
1267            backoff_factor: 2.0,
1268            jitter_ms: 0,
1269            operation_timeout_ms: None,
1270            immediate_first: false,
1271            max_elapsed_ms: None,
1272        };
1273        let manager = RetryManager::new(config);
1274
1275        let token = CancellationToken::new();
1276        let token_clone = token.clone();
1277
1278        // Cancel after a short delay
1279        spawn(async move {
1280            time::sleep(Duration::from_millis(50)).await;
1281            token_clone.cancel();
1282        });
1283
1284        let start = time::Instant::now();
1285        let result = manager
1286            .execute_with_retry_with_cancel(
1287                "test_cancellation_during_op",
1288                || async {
1289                    // Long-running operation
1290                    time::sleep(Duration::from_millis(200)).await;
1291                    Ok::<i32, TestError>(42)
1292                },
1293                should_retry_test_error,
1294                create_test_error,
1295                &token,
1296            )
1297            .await;
1298
1299        let elapsed = start.elapsed();
1300
1301        // Should be canceled during the operation
1302        assert!(result.is_err());
1303        let error_msg = format!("{}", result.unwrap_err());
1304        assert!(error_msg.contains("canceled"));
1305
1306        // Should not have completed the long operation
1307        assert!(elapsed.as_millis() < 250);
1308    }
1309
1310    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1311    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1312    async fn test_cancellation_error_message() {
1313        use tokio_util::sync::CancellationToken;
1314
1315        let config = RetryConfig::default();
1316        let manager = RetryManager::new(config);
1317
1318        let token = CancellationToken::new();
1319        token.cancel(); // Pre-cancel for immediate cancellation
1320
1321        let result = manager
1322            .execute_with_retry_with_cancel(
1323                "test_operation",
1324                || async { Ok::<i32, TestError>(42) },
1325                should_retry_test_error,
1326                create_test_error,
1327                &token,
1328            )
1329            .await;
1330
1331        assert!(result.is_err());
1332        let error_msg = format!("{}", result.unwrap_err());
1333        assert!(error_msg.contains("canceled"));
1334    }
1335}
1336
1337#[cfg(test)]
1338mod proptest_tests {
1339    use std::sync::{
1340        Arc,
1341        atomic::{AtomicU32, Ordering},
1342    };
1343
1344    #[cfg(all(feature = "simulation", madsim))]
1345    use madsim::task::spawn;
1346    use nautilus_core::MUTEX_POISONED;
1347    use proptest::prelude::*;
1348    // Import rstest attribute macro used within proptest! tests
1349    use rstest::rstest;
1350    #[cfg(not(all(feature = "simulation", madsim)))]
1351    use tokio::task::spawn;
1352
1353    #[cfg(not(all(feature = "simulation", madsim)))]
1354    use super::tests::{advance_until, yield_until};
1355    use super::{test_utils::*, tests::advance_clock, *};
1356    use crate::dst::time;
1357
1358    // Each proptest case constructs a runtime to drive the manager via
1359    // `block_on`. Under tokio, that runtime is paused so virtual sleeps
1360    // auto-advance; under madsim, the runtime is the deterministic sim
1361    // runtime, which also runs in virtual time. Both expose `block_on`.
1362    #[cfg(all(feature = "simulation", madsim))]
1363    fn build_paused_runtime() -> madsim::runtime::Runtime {
1364        madsim::runtime::Runtime::new()
1365    }
1366
1367    #[cfg(not(all(feature = "simulation", madsim)))]
1368    fn build_paused_runtime() -> tokio::runtime::Runtime {
1369        tokio::runtime::Builder::new_current_thread()
1370            .enable_time()
1371            .start_paused(true)
1372            .build()
1373            .unwrap()
1374    }
1375
1376    proptest! {
1377        #[rstest]
1378        fn test_retry_config_valid_ranges(
1379            max_retries in 0u32..100,
1380            initial_delay_ms in 1u64..10_000,
1381            max_delay_ms in 1u64..60_000,
1382            backoff_factor in 1.0f64..10.0,
1383            jitter_ms in 0u64..1_000,
1384            operation_timeout_ms in prop::option::of(1u64..120_000),
1385            immediate_first in any::<bool>(),
1386            max_elapsed_ms in prop::option::of(1u64..300_000)
1387        ) {
1388            // Ensure max_delay >= initial_delay for valid config
1389            let max_delay_ms = max_delay_ms.max(initial_delay_ms);
1390
1391            let config = RetryConfig {
1392                max_retries,
1393                initial_delay_ms,
1394                max_delay_ms,
1395                backoff_factor,
1396                jitter_ms,
1397                operation_timeout_ms,
1398                immediate_first,
1399                max_elapsed_ms,
1400            };
1401
1402            // Should always be able to create a RetryManager with valid config
1403            let _manager = RetryManager::<std::io::Error>::new(config);
1404        }
1405
1406        #[rstest]
1407        fn test_retry_attempts_bounded(
1408            max_retries in 0u32..5,
1409            initial_delay_ms in 1u64..10,
1410            backoff_factor in 1.0f64..2.0,
1411        ) {
1412            let rt = build_paused_runtime();
1413
1414            let config = RetryConfig {
1415                max_retries,
1416                initial_delay_ms,
1417                max_delay_ms: initial_delay_ms * 2,
1418                backoff_factor,
1419                jitter_ms: 0,
1420                operation_timeout_ms: None,
1421                immediate_first: false,
1422                max_elapsed_ms: None,
1423            };
1424
1425            let manager = RetryManager::new(config);
1426            let attempt_counter = Arc::new(AtomicU32::new(0));
1427            let counter_clone = attempt_counter.clone();
1428
1429            let _result = rt.block_on(manager.execute_with_retry(
1430                "prop_test",
1431                move || {
1432                    let counter = counter_clone.clone();
1433                    async move {
1434                        counter.fetch_add(1, Ordering::SeqCst);
1435                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1436                    }
1437                },
1438                |e: &TestError| matches!(e, TestError::Retryable(_)),
1439                TestError::Timeout,
1440            ));
1441
1442            let attempts = attempt_counter.load(Ordering::SeqCst);
1443            // Total attempts should be 1 (initial) + max_retries
1444            prop_assert_eq!(attempts, max_retries + 1);
1445        }
1446
1447        #[rstest]
1448        fn test_timeout_always_respected(
1449            timeout_ms in 10u64..50,
1450            operation_delay_ms in 60u64..100,
1451        ) {
1452            let rt = build_paused_runtime();
1453
1454            let config = RetryConfig {
1455                max_retries: 0, // No retries to isolate timeout behavior
1456                initial_delay_ms: 10,
1457                max_delay_ms: 100,
1458                backoff_factor: 2.0,
1459                jitter_ms: 0,
1460                operation_timeout_ms: Some(timeout_ms),
1461                immediate_first: false,
1462                max_elapsed_ms: None,
1463            };
1464
1465            let manager = RetryManager::new(config);
1466
1467            let result = rt.block_on(async {
1468                let operation_future = manager.execute_with_retry(
1469                    "timeout_test",
1470                    move || async move {
1471                        time::sleep(Duration::from_millis(operation_delay_ms)).await;
1472                        Ok::<i32, TestError>(42)
1473                    },
1474                    |_: &TestError| true,
1475                    TestError::Timeout,
1476                );
1477
1478                // Advance time to trigger timeout
1479                advance_clock(Duration::from_millis(timeout_ms + 10)).await;
1480                operation_future.await
1481            });
1482
1483            // Operation should timeout
1484            prop_assert!(result.is_err());
1485            prop_assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1486        }
1487
1488        #[rstest]
1489        fn test_max_elapsed_always_respected(
1490            max_elapsed_ms in 20u64..50,
1491            delay_per_retry in 15u64..30,
1492            max_retries in 10u32..20,
1493        ) {
1494            let rt = build_paused_runtime();
1495
1496            // Set up config where we would exceed max_elapsed_ms before max_retries
1497            let config = RetryConfig {
1498                max_retries,
1499                initial_delay_ms: delay_per_retry,
1500                max_delay_ms: delay_per_retry * 2,
1501                backoff_factor: 1.0, // No backoff to make timing predictable
1502                jitter_ms: 0,
1503                operation_timeout_ms: None,
1504                immediate_first: false,
1505                max_elapsed_ms: Some(max_elapsed_ms),
1506            };
1507
1508            let manager = RetryManager::new(config);
1509            let attempt_counter = Arc::new(AtomicU32::new(0));
1510            let counter_clone = attempt_counter.clone();
1511
1512            let result = rt.block_on(async {
1513                let operation_future = manager.execute_with_retry(
1514                    "elapsed_test",
1515                    move || {
1516                        let counter = counter_clone.clone();
1517                        async move {
1518                            counter.fetch_add(1, Ordering::SeqCst);
1519                            Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1520                        }
1521                    },
1522                    |e: &TestError| matches!(e, TestError::Retryable(_)),
1523                    TestError::Timeout,
1524                );
1525
1526                // Advance time past max_elapsed_ms
1527                advance_clock(Duration::from_millis(max_elapsed_ms + delay_per_retry)).await;
1528                operation_future.await
1529            });
1530
1531            let attempts = attempt_counter.load(Ordering::SeqCst);
1532
1533            // Should have failed with timeout error
1534            prop_assert!(result.is_err());
1535            prop_assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1536
1537            // Should have stopped before exhausting all retries
1538            prop_assert!(attempts <= max_retries + 1);
1539        }
1540
1541        #[rstest]
1542        fn test_jitter_bounds(
1543            jitter_ms in 0u64..20,
1544            base_delay_ms in 10u64..30,
1545        ) {
1546            let rt = build_paused_runtime();
1547
1548            let config = RetryConfig {
1549                max_retries: 2,
1550                initial_delay_ms: base_delay_ms,
1551                max_delay_ms: base_delay_ms * 2,
1552                backoff_factor: 1.0, // No backoff to isolate jitter
1553                jitter_ms,
1554                operation_timeout_ms: None,
1555                immediate_first: false,
1556                max_elapsed_ms: None,
1557            };
1558
1559            let manager = RetryManager::new(config);
1560            let attempt_times = Arc::new(std::sync::Mutex::new(Vec::new()));
1561            let attempt_times_for_block = attempt_times.clone();
1562
1563            rt.block_on(async move {
1564                #[cfg(not(all(feature = "simulation", madsim)))]
1565                let attempt_times_for_wait = attempt_times_for_block.clone();
1566                let handle = spawn({
1567                    let attempt_times_for_task = attempt_times_for_block.clone();
1568                    let manager = manager;
1569                    async move {
1570                        let start_time = time::Instant::now();
1571                        let _ = manager
1572                            .execute_with_retry(
1573                                "jitter_test",
1574                                move || {
1575                                    let attempt_times_inner = attempt_times_for_task.clone();
1576                                    async move {
1577                                        attempt_times_inner
1578                                            .lock()
1579                                            .unwrap()
1580                                            .push(start_time.elapsed());
1581                                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1582                                    }
1583                                },
1584                                |e: &TestError| matches!(e, TestError::Retryable(_)),
1585                                TestError::Timeout,
1586                            )
1587                            .await;
1588                    }
1589                });
1590
1591                // Under tokio paused clock, drive virtual time forward in 1ms
1592                // ticks to release the manager's sleeps; under madsim the
1593                // runtime auto-advances when all tasks block on virtual time,
1594                // so awaiting the handle is enough and yields exact timings.
1595                #[cfg(not(all(feature = "simulation", madsim)))]
1596                {
1597                    yield_until(|| !attempt_times_for_wait.lock().expect(MUTEX_POISONED).is_empty()).await;
1598                    advance_until(|| attempt_times_for_wait.lock().expect(MUTEX_POISONED).len() >= 2).await;
1599                    advance_until(|| attempt_times_for_wait.lock().expect(MUTEX_POISONED).len() >= 3).await;
1600                }
1601
1602                handle.await.unwrap();
1603            });
1604
1605            let times = attempt_times.lock().expect(MUTEX_POISONED);
1606
1607            // We expect at least 2 attempts total (initial + at least 1 retry)
1608            prop_assert!(times.len() >= 2);
1609
1610            // First attempt should be immediate (no delay)
1611            prop_assert!(times[0].as_millis() < 5);
1612
1613            // Check subsequent retries have appropriate delays
1614            for i in 1..times.len() {
1615                let delay_from_previous = if i == 1 {
1616                    times[i].checked_sub(times[0]).unwrap()
1617                } else {
1618                    times[i].checked_sub(times[i - 1]).unwrap()
1619                };
1620
1621                // The delay floor is min(base, max - jitter): near the cap the
1622                // jittered base is lowered so the spread survives saturation
1623                let floor = base_delay_ms.min((base_delay_ms * 2).saturating_sub(jitter_ms));
1624                prop_assert!(
1625                    delay_from_previous.as_millis() >= u128::from(floor),
1626                    "Retry {} delay {}ms is less than floor {}ms",
1627                    i, delay_from_previous.as_millis(), floor
1628                );
1629
1630                // Delay should be at most base_delay + jitter
1631                prop_assert!(
1632                    delay_from_previous.as_millis() <= u128::from(base_delay_ms + jitter_ms + 1),
1633                    "Retry {} delay {}ms exceeds base {} + jitter {}",
1634                    i, delay_from_previous.as_millis(), base_delay_ms, jitter_ms
1635                );
1636            }
1637        }
1638
1639        #[rstest]
1640        fn test_immediate_first_property(
1641            immediate_first in any::<bool>(),
1642            initial_delay_ms in 10u64..30,
1643        ) {
1644            let rt = build_paused_runtime();
1645
1646            let config = RetryConfig {
1647                max_retries: 2,
1648                initial_delay_ms,
1649                max_delay_ms: initial_delay_ms * 2,
1650                backoff_factor: 2.0,
1651                jitter_ms: 0,
1652                operation_timeout_ms: None,
1653                immediate_first,
1654                max_elapsed_ms: None,
1655            };
1656
1657            let manager = RetryManager::new(config);
1658            let attempt_times = Arc::new(std::sync::Mutex::new(Vec::new()));
1659            let attempt_times_for_block = attempt_times.clone();
1660
1661            rt.block_on(async move {
1662                #[cfg(not(all(feature = "simulation", madsim)))]
1663                let attempt_times_for_wait = attempt_times_for_block.clone();
1664                let handle = spawn({
1665                    let attempt_times_for_task = attempt_times_for_block.clone();
1666                    let manager = manager;
1667                    async move {
1668                        let start = time::Instant::now();
1669                        let _ = manager
1670                            .execute_with_retry(
1671                                "immediate_test",
1672                                move || {
1673                                    let attempt_times_inner = attempt_times_for_task.clone();
1674                                    async move {
1675                                        let elapsed = start.elapsed();
1676                                        attempt_times_inner.lock().expect(MUTEX_POISONED).push(elapsed);
1677                                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1678                                    }
1679                                },
1680                                |e: &TestError| matches!(e, TestError::Retryable(_)),
1681                                TestError::Timeout,
1682                            )
1683                            .await;
1684                    }
1685                });
1686
1687                // See test_jitter_bounds: madsim auto-advances virtual time
1688                // when all tasks block on it, so awaiting the handle suffices
1689                // and avoids the 1ms-tick driver's added scheduler overhead.
1690                #[cfg(not(all(feature = "simulation", madsim)))]
1691                {
1692                    yield_until(|| !attempt_times_for_wait.lock().expect(MUTEX_POISONED).is_empty()).await;
1693                    advance_until(|| attempt_times_for_wait.lock().expect(MUTEX_POISONED).len() >= 2).await;
1694                    advance_until(|| attempt_times_for_wait.lock().expect(MUTEX_POISONED).len() >= 3).await;
1695                }
1696
1697                handle.await.unwrap();
1698            });
1699
1700            let times = attempt_times.lock().expect(MUTEX_POISONED);
1701            prop_assert!(times.len() >= 2);
1702
1703            if immediate_first {
1704                // First retry should be immediate
1705                prop_assert!(times[1].as_millis() < 20,
1706                    "With immediate_first=true, first retry took {}ms",
1707                    times[1].as_millis());
1708            } else {
1709                // First retry should have delay
1710                prop_assert!(times[1].as_millis() >= u128::from(initial_delay_ms - 1),
1711                    "With immediate_first=false, first retry was too fast: {}ms",
1712                    times[1].as_millis());
1713            }
1714        }
1715
1716        #[rstest]
1717        fn test_non_retryable_stops_immediately(
1718            attempt_before_non_retryable in 0usize..3,
1719            max_retries in 3u32..5,
1720        ) {
1721            let rt = build_paused_runtime();
1722
1723            let config = RetryConfig {
1724                max_retries,
1725                initial_delay_ms: 10,
1726                max_delay_ms: 100,
1727                backoff_factor: 2.0,
1728                jitter_ms: 0,
1729                operation_timeout_ms: None,
1730                immediate_first: false,
1731                max_elapsed_ms: None,
1732            };
1733
1734            let manager = RetryManager::new(config);
1735            let attempt_counter = Arc::new(AtomicU32::new(0));
1736            let counter_clone = attempt_counter.clone();
1737
1738            let result: Result<i32, TestError> = rt.block_on(manager.execute_with_retry(
1739                "non_retryable_test",
1740                move || {
1741                    let counter = counter_clone.clone();
1742                    async move {
1743                        let attempts = counter.fetch_add(1, Ordering::SeqCst) as usize;
1744                        if attempts == attempt_before_non_retryable {
1745                            Err(TestError::NonRetryable("stop".to_string()))
1746                        } else {
1747                            Err(TestError::Retryable("retry".to_string()))
1748                        }
1749                    }
1750                },
1751                |e: &TestError| matches!(e, TestError::Retryable(_)),
1752                TestError::Timeout,
1753            ));
1754
1755            let attempts = attempt_counter.load(Ordering::SeqCst) as usize;
1756
1757            prop_assert!(result.is_err());
1758            prop_assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
1759            // Should stop exactly when non-retryable error occurs
1760            prop_assert_eq!(attempts, attempt_before_non_retryable + 1);
1761        }
1762
1763        #[rstest]
1764        fn test_cancellation_stops_immediately(
1765            cancel_after_ms in 10u64..100,
1766            initial_delay_ms in 200u64..500,
1767        ) {
1768            use tokio_util::sync::CancellationToken;
1769
1770            let rt = build_paused_runtime();
1771
1772            let config = RetryConfig {
1773                max_retries: 10,
1774                initial_delay_ms,
1775                max_delay_ms: initial_delay_ms * 2,
1776                backoff_factor: 2.0,
1777                jitter_ms: 0,
1778                operation_timeout_ms: None,
1779                immediate_first: false,
1780                max_elapsed_ms: None,
1781            };
1782
1783            let manager = RetryManager::new(config);
1784            let token = CancellationToken::new();
1785            let token_clone = token.clone();
1786
1787            let result: Result<i32, TestError> = rt.block_on(async {
1788                // Spawn cancellation task
1789                spawn(async move {
1790                    time::sleep(Duration::from_millis(cancel_after_ms)).await;
1791                    token_clone.cancel();
1792                });
1793
1794                let operation_future = manager.execute_with_retry_with_cancel(
1795                    "cancellation_test",
1796                    || async {
1797                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1798                    },
1799                    |e: &TestError| matches!(e, TestError::Retryable(_)),
1800                    create_test_error,
1801                    &token,
1802                );
1803
1804                // Advance time to trigger cancellation
1805                advance_clock(Duration::from_millis(cancel_after_ms + 10)).await;
1806                operation_future.await
1807            });
1808
1809            // Should be canceled
1810            prop_assert!(result.is_err());
1811            let error_msg = format!("{}", result.unwrap_err());
1812            prop_assert!(error_msg.contains("canceled"));
1813        }
1814
1815        #[rstest]
1816        fn test_budget_clamp_prevents_overshoot(
1817            max_elapsed_ms in 10u64..30,
1818            delay_per_retry in 20u64..50,
1819        ) {
1820            let rt = build_paused_runtime();
1821
1822            // Configure so that first retry delay would exceed budget
1823            let config = RetryConfig {
1824                max_retries: 5,
1825                initial_delay_ms: delay_per_retry,
1826                max_delay_ms: delay_per_retry * 2,
1827                backoff_factor: 1.0,
1828                jitter_ms: 0,
1829                operation_timeout_ms: None,
1830                immediate_first: false,
1831                max_elapsed_ms: Some(max_elapsed_ms),
1832            };
1833
1834            let manager = RetryManager::new(config);
1835
1836            let _result = rt.block_on(async {
1837                let operation_future = manager.execute_with_retry(
1838                    "budget_clamp_test",
1839                    || async {
1840                        // Fast operation to focus on delay timing
1841                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1842                    },
1843                    |e: &TestError| matches!(e, TestError::Retryable(_)),
1844                    create_test_error,
1845                );
1846
1847                // Advance time past max_elapsed_ms
1848                advance_clock(Duration::from_millis(max_elapsed_ms + delay_per_retry)).await;
1849                operation_future.await
1850            });
1851
1852            // With deterministic time, operation completes without wall-clock delay
1853            // The budget constraint is still enforced by the retry manager
1854        }
1855
1856        #[rstest]
1857        fn test_success_on_kth_attempt(
1858            k in 1usize..5,
1859            initial_delay_ms in 5u64..20,
1860        ) {
1861            let rt = build_paused_runtime();
1862
1863            let config = RetryConfig {
1864                max_retries: 10, // More than k
1865                initial_delay_ms,
1866                max_delay_ms: initial_delay_ms * 4,
1867                backoff_factor: 2.0,
1868                jitter_ms: 0,
1869                operation_timeout_ms: None,
1870                immediate_first: false,
1871                max_elapsed_ms: None,
1872            };
1873
1874            let manager = RetryManager::new(config);
1875            let attempt_counter = Arc::new(AtomicU32::new(0));
1876            let counter_clone = attempt_counter.clone();
1877            let target_k = k;
1878
1879            let (result, _elapsed) = rt.block_on(async {
1880                let start = time::Instant::now();
1881
1882                let operation_future = manager.execute_with_retry(
1883                    "kth_attempt_test",
1884                    move || {
1885                        let counter = counter_clone.clone();
1886                        async move {
1887                            let attempt = counter.fetch_add(1, Ordering::SeqCst) as usize;
1888                            if attempt + 1 == target_k {
1889                                Ok(42)
1890                            } else {
1891                                Err(TestError::Retryable("retry".to_string()))
1892                            }
1893                        }
1894                    },
1895                    |e: &TestError| matches!(e, TestError::Retryable(_)),
1896                    create_test_error,
1897                );
1898
1899                // Advance time to allow enough retries
1900                for _ in 0..k {
1901                    advance_clock(Duration::from_millis(initial_delay_ms * 4)).await;
1902                }
1903
1904                let result = operation_future.await;
1905                let elapsed = start.elapsed();
1906
1907                (result, elapsed)
1908            });
1909
1910            let attempts = attempt_counter.load(Ordering::SeqCst) as usize;
1911
1912            // Using paused Tokio time (start_paused + advance); assert behavior only (no wall-clock timing)
1913            prop_assert!(result.is_ok());
1914            prop_assert_eq!(result.unwrap(), 42);
1915            prop_assert_eq!(attempts, k);
1916        }
1917    }
1918}