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//! Retry policy for asynchronous network operations.
17
18use std::{fmt::Display, 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. `None` disables the timeout.
40    pub operation_timeout_ms: Option<u64>,
41    /// Whether the first retry occurs without delay.
42    ///
43    /// Connection operations typically enable this, while HTTP and order operations typically
44    /// retain a delay.
45    pub immediate_first: bool,
46    /// Optional maximum total elapsed time across all attempts and retry delays in milliseconds.
47    /// When set, this deadline also bounds an in-flight operation.
48    pub max_elapsed_ms: Option<u64>,
49}
50
51impl Default for RetryConfig {
52    fn default() -> Self {
53        Self {
54            max_retries: 3,
55            initial_delay_ms: 1_000,
56            max_delay_ms: 10_000,
57            backoff_factor: 2.0,
58            jitter_ms: 100,
59            operation_timeout_ms: Some(30_000),
60            immediate_first: false,
61            max_elapsed_ms: None,
62        }
63    }
64}
65
66/// A failure synthesized by retry machinery.
67///
68/// This type describes the retry control path only. It does not indicate whether an operation was
69/// transmitted or applied.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum RetryError {
72    /// The cancellation token was set.
73    Canceled,
74    /// A single operation attempt exceeded its configured timeout.
75    OperationTimeout {
76        /// Configured timeout for each attempt in milliseconds.
77        timeout_ms: u64,
78    },
79    /// The total elapsed-time budget was exhausted.
80    ElapsedBudgetExceeded {
81        /// One-based attempt position when the budget was exhausted.
82        attempt: u32,
83        /// Maximum number of attempts allowed by the retry configuration.
84        max_attempts: u32,
85        /// Last operation error when budget exhaustion followed a failed attempt.
86        last_error: Option<String>,
87    },
88    /// The retry configuration could not create a backoff state.
89    InvalidConfiguration {
90        /// Configuration validation error.
91        message: String,
92    },
93}
94
95impl Display for RetryError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            Self::Canceled => write!(f, "canceled"),
99            Self::OperationTimeout { timeout_ms } => {
100                write!(f, "Timed out after {timeout_ms}ms")
101            }
102            Self::ElapsedBudgetExceeded {
103                attempt,
104                max_attempts,
105                last_error,
106            } => {
107                write!(f, "Retry budget exceeded ({attempt}/{max_attempts})")?;
108                if let Some(last_error) = last_error {
109                    write!(f, ": last error: {last_error}")?;
110                }
111                Ok(())
112            }
113            Self::InvalidConfiguration { message } => {
114                write!(f, "Invalid configuration: {message}")
115            }
116        }
117    }
118}
119
120impl std::error::Error for RetryError {}
121
122/// A stateless, thread-safe retry manager for network operations.
123///
124/// Each execution maintains independent backoff and elapsed-time state.
125#[derive(Clone, Debug)]
126pub struct RetryManager<E> {
127    config: RetryConfig,
128    _phantom: PhantomData<E>,
129}
130
131impl<E> RetryManager<E>
132where
133    E: std::error::Error,
134{
135    /// Creates a new retry manager with the given configuration.
136    #[must_use]
137    pub const fn new(config: RetryConfig) -> Self {
138        Self {
139            config,
140            _phantom: PhantomData,
141        }
142    }
143
144    /// Creates a retry budget error with attempt context.
145    #[inline(always)]
146    fn budget_exceeded_error(&self, attempt: u32, last_error: Option<String>) -> RetryError {
147        RetryError::ElapsedBudgetExceeded {
148            attempt: attempt.saturating_add(1),
149            max_attempts: self.config.max_retries.saturating_add(1),
150            last_error,
151        }
152    }
153
154    /// Executes an operation with retry logic and optional cancellation.
155    ///
156    /// Cancellation is checked at three points:
157    ///
158    /// - Before each operation attempt.
159    /// - During operation execution through `tokio::select!`.
160    /// - During retry delays.
161    ///
162    /// Cancellation mid-execution takes effect immediately by dropping the in-flight
163    /// operation future. For non-idempotent operations (e.g. an order already on the
164    /// wire) the outcome of the abandoned attempt is unknown to the caller.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the operation fails after exhausting all retries,
169    /// if the operation times out, if creating the backoff state fails, or if canceled.
170    pub async fn execute_with_retry_inner<F, Fut, T>(
171        &self,
172        operation_name: &str,
173        operation: F,
174        should_retry: impl Fn(&E) -> bool,
175        create_error: impl Fn(RetryError) -> E,
176        cancel: Option<&CancellationToken>,
177    ) -> Result<T, E>
178    where
179        F: FnMut() -> Fut,
180        Fut: Future<Output = Result<T, E>>,
181    {
182        self.execute_with_retry_inner_delay(
183            operation_name,
184            operation,
185            should_retry,
186            |_| None,
187            create_error,
188            cancel,
189        )
190        .await
191    }
192
193    async fn execute_with_retry_inner_delay<F, Fut, T>(
194        &self,
195        operation_name: &str,
196        mut operation: F,
197        should_retry: impl Fn(&E) -> bool,
198        retry_delay: impl Fn(&E) -> Option<Duration>,
199        create_error: impl Fn(RetryError) -> E,
200        cancel: Option<&CancellationToken>,
201    ) -> Result<T, E>
202    where
203        F: FnMut() -> Fut,
204        Fut: Future<Output = Result<T, E>>,
205    {
206        let mut backoff = ExponentialBackoff::new(
207            Duration::from_millis(self.config.initial_delay_ms),
208            Duration::from_millis(self.config.max_delay_ms),
209            self.config.backoff_factor,
210            self.config.jitter_ms,
211            self.config.immediate_first,
212        )
213        .map_err(|e| {
214            create_error(RetryError::InvalidConfiguration {
215                message: e.to_string(),
216            })
217        })?;
218
219        let mut attempt = 0;
220        let start_time = dst::time::Instant::now();
221        let max_elapsed = self.config.max_elapsed_ms.map(Duration::from_millis);
222        let deadline = max_elapsed.and_then(|duration| start_time.checked_add(duration));
223        let mut last_delayed_error = None;
224
225        loop {
226            if let Some(token) = cancel
227                && token.is_cancelled()
228            {
229                log::debug!("Operation '{operation_name}' canceled after {attempt} attempts");
230                return Err(create_error(RetryError::Canceled));
231            }
232
233            if let Some(max_elapsed) = max_elapsed {
234                let elapsed = start_time.elapsed();
235                if elapsed >= max_elapsed {
236                    if let Some(e) = last_delayed_error {
237                        return Err(e);
238                    }
239                    return Err(create_error(self.budget_exceeded_error(attempt, None)));
240                }
241            }
242            last_delayed_error = None;
243
244            let attempt_future = async {
245                let result = match (self.config.operation_timeout_ms, cancel) {
246                    (Some(timeout_ms), Some(token)) => {
247                        tokio::select! {
248                            biased;
249                            result = dst::time::timeout(Duration::from_millis(timeout_ms), operation()) => result,
250                            () = token.cancelled() => {
251                                log::debug!("Operation '{operation_name}' canceled during execution");
252                                return Err(create_error(RetryError::Canceled));
253                            }
254                        }
255                    }
256                    (Some(timeout_ms), None) => {
257                        dst::time::timeout(Duration::from_millis(timeout_ms), operation()).await
258                    }
259                    (None, Some(token)) => tokio::select! {
260                        biased;
261                        result = operation() => Ok(result),
262                        () = token.cancelled() => {
263                            log::debug!("Operation '{operation_name}' canceled during execution");
264                            return Err(create_error(RetryError::Canceled));
265                        }
266                    },
267                    (None, None) => Ok(operation().await),
268                };
269                Ok(result)
270            };
271            let result = if let Some(deadline) = deadline {
272                tokio::select! {
273                    biased;
274                    () = dst::time::sleep_until(deadline) => {
275                        if cancel.is_some_and(CancellationToken::is_cancelled) {
276                            log::debug!("Operation '{operation_name}' canceled during execution");
277                            return Err(create_error(RetryError::Canceled));
278                        }
279                        return Err(create_error(self.budget_exceeded_error(attempt, None)));
280                    }
281                    result = attempt_future => result,
282                }
283            } else {
284                attempt_future.await
285            }?;
286
287            let (e, minimum_delay, timed_out) = match result {
288                Ok(Ok(success)) => {
289                    if attempt > 0 {
290                        log::trace!(
291                            "Operation '{operation_name}' succeeded after {} attempts",
292                            attempt + 1
293                        );
294                    }
295                    return Ok(success);
296                }
297                Ok(Err(e)) => {
298                    let minimum_delay = retry_delay(&e);
299                    (e, minimum_delay, false)
300                }
301                Err(_) => (
302                    create_error(RetryError::OperationTimeout {
303                        timeout_ms: self.config.operation_timeout_ms.unwrap_or(0),
304                    }),
305                    None,
306                    true,
307                ),
308            };
309
310            if !should_retry(&e) {
311                if timed_out {
312                    log::trace!("Operation '{operation_name}' non-retryable timeout: {e}");
313                } else {
314                    log::trace!("Operation '{operation_name}' non-retryable error: {e}");
315                }
316                return Err(e);
317            }
318
319            if attempt >= self.config.max_retries {
320                if timed_out {
321                    log::trace!(
322                        "Operation '{operation_name}' retries exhausted after timeout ({} attempts): {e}",
323                        attempt + 1
324                    );
325                } else {
326                    log::trace!(
327                        "Operation '{operation_name}' retries exhausted after {} attempts: {e}",
328                        attempt + 1
329                    );
330                }
331                return Err(e);
332            }
333
334            let mut delay = backoff.next_duration();
335
336            if let Some(minimum_delay) = minimum_delay {
337                delay = delay.max(minimum_delay);
338            }
339
340            if let Some(max_elapsed_ms) = self.config.max_elapsed_ms {
341                let elapsed = start_time.elapsed();
342                let remaining = Duration::from_millis(max_elapsed_ms).saturating_sub(elapsed);
343
344                if remaining.is_zero() {
345                    if minimum_delay.is_some() {
346                        return Err(e);
347                    }
348                    return Err(create_error(
349                        self.budget_exceeded_error(attempt, Some(e.to_string())),
350                    ));
351                }
352
353                if minimum_delay.is_some() && delay >= remaining {
354                    return Err(e);
355                }
356                delay = delay.min(remaining);
357            }
358
359            debug_assert!(
360                minimum_delay.is_none_or(|minimum_delay| delay >= minimum_delay),
361                "retry delay must honor the error-provided minimum"
362            );
363
364            if timed_out {
365                log::trace!(
366                    "Operation '{operation_name}' attempt {} timed out, retrying in {}ms: {e}",
367                    attempt + 1,
368                    delay.as_millis()
369                );
370            } else {
371                log::trace!(
372                    "Operation '{operation_name}' attempt {} failed, retrying in {}ms: {e}",
373                    attempt + 1,
374                    delay.as_millis()
375                );
376            }
377
378            // Yield even on zero-delay to avoid busy-wait loop
379            if delay.is_zero() {
380                tokio::task::yield_now().await;
381
382                if minimum_delay.is_some() {
383                    last_delayed_error = Some(e);
384                }
385                attempt += 1;
386                continue;
387            }
388
389            if let Some(token) = cancel {
390                tokio::select! {
391                    biased;
392                    () = dst::time::sleep(delay) => {},
393                    () = token.cancelled() => {
394                        log::debug!("Operation '{operation_name}' canceled during retry delay (attempt {})", attempt + 1);
395                        return Err(create_error(RetryError::Canceled));
396                    }
397                }
398            } else {
399                dst::time::sleep(delay).await;
400            }
401
402            if minimum_delay.is_some() {
403                last_delayed_error = Some(e);
404            }
405
406            attempt += 1;
407        }
408    }
409
410    /// Executes an operation with retry logic.
411    ///
412    /// # Errors
413    ///
414    /// Returns an error if the operation fails after exhausting all retries,
415    /// if the operation times out, or if creating the backoff state fails.
416    pub async fn execute_with_retry<F, Fut, T>(
417        &self,
418        operation_name: &str,
419        operation: F,
420        should_retry: impl Fn(&E) -> bool,
421        create_error: impl Fn(RetryError) -> E,
422    ) -> Result<T, E>
423    where
424        F: FnMut() -> Fut,
425        Fut: Future<Output = Result<T, E>>,
426    {
427        self.execute_with_retry_inner(operation_name, operation, should_retry, create_error, None)
428            .await
429    }
430
431    /// Executes an operation with retry logic and an error-provided minimum retry delay.
432    ///
433    /// The delay runs between attempts and does not consume the per-operation timeout. If the
434    /// required delay cannot fit within the remaining retry budget, the original error is returned.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if the operation fails after exhausting all retries,
439    /// if the operation times out, or if creating the backoff state fails.
440    pub async fn execute_with_retry_with_delay<F, Fut, T>(
441        &self,
442        operation_name: &str,
443        operation: F,
444        should_retry: impl Fn(&E) -> bool,
445        retry_delay: impl Fn(&E) -> Option<Duration>,
446        create_error: impl Fn(RetryError) -> E,
447    ) -> Result<T, E>
448    where
449        F: FnMut() -> Fut,
450        Fut: Future<Output = Result<T, E>>,
451    {
452        self.execute_with_retry_inner_delay(
453            operation_name,
454            operation,
455            should_retry,
456            retry_delay,
457            create_error,
458            None,
459        )
460        .await
461    }
462
463    /// Executes an operation with retry logic and cancellation support.
464    ///
465    /// # Errors
466    ///
467    /// Returns an error if the operation fails after exhausting all retries,
468    /// if the operation times out, if creating the backoff state fails, or if canceled.
469    pub async fn execute_with_retry_with_cancel<F, Fut, T>(
470        &self,
471        operation_name: &str,
472        operation: F,
473        should_retry: impl Fn(&E) -> bool,
474        create_error: impl Fn(RetryError) -> E,
475        cancellation_token: &CancellationToken,
476    ) -> Result<T, E>
477    where
478        F: FnMut() -> Fut,
479        Fut: Future<Output = Result<T, E>>,
480    {
481        self.execute_with_retry_inner(
482            operation_name,
483            operation,
484            should_retry,
485            create_error,
486            Some(cancellation_token),
487        )
488        .await
489    }
490}
491
492/// Convenience function to create a retry manager with default configuration.
493#[must_use]
494pub fn create_default_retry_manager<E>() -> RetryManager<E>
495where
496    E: std::error::Error,
497{
498    RetryManager::new(RetryConfig::default())
499}
500
501/// Convenience function to create a retry manager for HTTP operations.
502#[must_use]
503pub const fn create_http_retry_manager<E>() -> RetryManager<E>
504where
505    E: std::error::Error,
506{
507    let config = RetryConfig {
508        max_retries: 3,
509        initial_delay_ms: 1_000,
510        max_delay_ms: 10_000,
511        backoff_factor: 2.0,
512        jitter_ms: 1_000,
513        operation_timeout_ms: Some(60_000), // 60s for HTTP requests
514        immediate_first: false,
515        max_elapsed_ms: Some(180_000), // 3 minutes total budget
516    };
517    RetryManager::new(config)
518}
519
520/// Convenience function to create a retry manager for WebSocket operations.
521#[must_use]
522pub const fn create_websocket_retry_manager<E>() -> RetryManager<E>
523where
524    E: std::error::Error,
525{
526    let config = RetryConfig {
527        max_retries: 5,
528        initial_delay_ms: 1_000,
529        max_delay_ms: 10_000,
530        backoff_factor: 2.0,
531        jitter_ms: 1_000,
532        operation_timeout_ms: Some(30_000), // 30s for WebSocket operations
533        immediate_first: true,
534        max_elapsed_ms: Some(120_000), // 2 minutes total budget
535    };
536    RetryManager::new(config)
537}
538
539#[cfg(test)]
540mod test_utils {
541    use super::RetryError;
542
543    #[derive(Debug, thiserror::Error)]
544    pub(super) enum TestError {
545        #[error("Retryable error: {0}")]
546        Retryable(String),
547        #[error("Non-retryable error: {0}")]
548        NonRetryable(String),
549        #[error("Timeout error: {0}")]
550        Timeout(RetryError),
551    }
552
553    pub(super) fn should_retry_test_error(error: &TestError) -> bool {
554        matches!(error, TestError::Retryable(_))
555    }
556
557    pub(super) fn create_test_error(error: RetryError) -> TestError {
558        TestError::Timeout(error)
559    }
560}
561
562// Retry tests run under both real tokio (`#[tokio::test]`, paused-clock when
563// the test relies on virtual time advance) and madsim (`#[madsim::test]`,
564// virtual time always paused). `tokio::time::advance` has no direct madsim
565// equivalent, so explicit clock advances route through `advance_clock` below;
566// time reads and sleeps go through the `dst::time` re-export so they pick up
567// the runtime-appropriate clock. madsim auto-advances virtual time when all
568// tasks block, but `yield_until`-style busy-yield loops keep the runtime
569// non-idle, so explicit advances are still needed where they were before.
570#[cfg(test)]
571mod tests {
572    use std::sync::{
573        Arc,
574        atomic::{AtomicBool, AtomicU32, Ordering},
575    };
576
577    #[cfg(all(feature = "simulation", madsim))]
578    use madsim::task::{spawn, yield_now};
579    use rstest::rstest;
580    #[cfg(not(all(feature = "simulation", madsim)))]
581    use tokio::task::{spawn, yield_now};
582
583    use super::{test_utils::*, *};
584    use crate::dst::time;
585
586    const MAX_WAIT_ITERS: usize = 10_000;
587    const MAX_ADVANCE_ITERS: usize = 10_000;
588
589    #[cfg(all(feature = "simulation", madsim))]
590    pub(crate) async fn advance_clock(d: Duration) {
591        madsim::time::advance(d);
592        madsim::task::yield_now().await;
593    }
594
595    #[cfg(not(all(feature = "simulation", madsim)))]
596    pub(crate) async fn advance_clock(d: Duration) {
597        tokio::time::advance(d).await;
598    }
599
600    pub(crate) async fn yield_until<F>(mut condition: F)
601    where
602        F: FnMut() -> bool,
603    {
604        for _ in 0..MAX_WAIT_ITERS {
605            if condition() {
606                return;
607            }
608            yield_now().await;
609        }
610
611        panic!("yield_until timed out waiting for condition");
612    }
613
614    pub(crate) async fn advance_until<F>(mut condition: F)
615    where
616        F: FnMut() -> bool,
617    {
618        for _ in 0..MAX_ADVANCE_ITERS {
619            if condition() {
620                return;
621            }
622            advance_clock(Duration::from_millis(1)).await;
623            yield_now().await;
624        }
625
626        panic!("advance_until timed out waiting for condition");
627    }
628
629    #[rstest]
630    fn test_retry_config_default() {
631        let config = RetryConfig::default();
632        assert_eq!(config.max_retries, 3);
633        assert_eq!(config.initial_delay_ms, 1_000);
634        assert_eq!(config.max_delay_ms, 10_000);
635        // `allow` not `expect`: nightly clippy does not fire `float_cmp` inside `assert_eq!`
636        #[allow(clippy::float_cmp, reason = "test asserts the default backoff factor")]
637        {
638            assert_eq!(config.backoff_factor, 2.0);
639        }
640        assert_eq!(config.jitter_ms, 100);
641        assert_eq!(config.operation_timeout_ms, Some(30_000));
642        assert!(!config.immediate_first);
643        assert_eq!(config.max_elapsed_ms, None);
644    }
645
646    #[rstest]
647    #[case::canceled(RetryError::Canceled, "canceled")]
648    #[case::operation_timeout(
649        RetryError::OperationTimeout { timeout_ms: 250 },
650        "Timed out after 250ms"
651    )]
652    #[case::elapsed_budget(
653        RetryError::ElapsedBudgetExceeded {
654            attempt: 2,
655            max_attempts: 4,
656            last_error: None,
657        },
658        "Retry budget exceeded (2/4)"
659    )]
660    #[case::elapsed_budget_with_last_error(
661        RetryError::ElapsedBudgetExceeded {
662            attempt: 3,
663            max_attempts: 5,
664            last_error: Some("network unavailable".to_string()),
665        },
666        "Retry budget exceeded (3/5): last error: network unavailable"
667    )]
668    #[case::invalid_configuration(
669        RetryError::InvalidConfiguration {
670            message: "delay_initial must be non-zero".to_string(),
671        },
672        "Invalid configuration: delay_initial must be non-zero"
673    )]
674    fn test_retry_error_display(#[case] error: RetryError, #[case] expected: &str) {
675        assert_eq!(error.to_string(), expected);
676    }
677
678    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
679    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
680    async fn test_invalid_configuration_reason() {
681        let manager = RetryManager::new(RetryConfig {
682            initial_delay_ms: 0,
683            ..RetryConfig::default()
684        });
685
686        let error = manager
687            .execute_with_retry(
688                "test_invalid_configuration",
689                || async { Ok::<i32, TestError>(42) },
690                should_retry_test_error,
691                create_test_error,
692            )
693            .await
694            .unwrap_err();
695
696        let TestError::Timeout(reason) = error else {
697            panic!("expected invalid configuration, was {error}");
698        };
699        assert_eq!(
700            reason,
701            RetryError::InvalidConfiguration {
702                message: "delay_initial must be non-zero".to_string(),
703            }
704        );
705    }
706
707    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
708    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
709    async fn test_retry_manager_success_first_attempt() {
710        let manager = RetryManager::new(RetryConfig::default());
711
712        let result = manager
713            .execute_with_retry(
714                "test_operation",
715                || async { Ok::<i32, TestError>(42) },
716                should_retry_test_error,
717                create_test_error,
718            )
719            .await;
720
721        assert_eq!(result.unwrap(), 42);
722    }
723
724    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
725    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
726    async fn test_retry_manager_non_retryable_error() {
727        let manager = RetryManager::new(RetryConfig::default());
728
729        let result = manager
730            .execute_with_retry(
731                "test_operation",
732                || async { Err::<i32, TestError>(TestError::NonRetryable("test".to_string())) },
733                should_retry_test_error,
734                create_test_error,
735            )
736            .await;
737
738        assert!(result.is_err());
739        assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
740    }
741
742    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
743    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
744    async fn test_retry_manager_retryable_error_exhausted() {
745        let config = RetryConfig {
746            max_retries: 2,
747            initial_delay_ms: 10,
748            max_delay_ms: 50,
749            backoff_factor: 2.0,
750            jitter_ms: 0,
751            operation_timeout_ms: None,
752            immediate_first: false,
753            max_elapsed_ms: None,
754        };
755        let manager = RetryManager::new(config);
756
757        let result = manager
758            .execute_with_retry(
759                "test_operation",
760                || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
761                should_retry_test_error,
762                create_test_error,
763            )
764            .await;
765
766        assert!(result.is_err());
767        assert!(matches!(result.unwrap_err(), TestError::Retryable(_)));
768    }
769
770    #[rstest]
771    #[cfg_attr(
772        not(all(feature = "simulation", madsim)),
773        tokio::test(start_paused = true)
774    )]
775    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
776    async fn test_error_retry_delay_runs_outside_operation_timeout() {
777        let config = RetryConfig {
778            max_retries: 1,
779            initial_delay_ms: 10,
780            max_delay_ms: 10,
781            backoff_factor: 1.0,
782            jitter_ms: 0,
783            operation_timeout_ms: Some(50),
784            immediate_first: false,
785            max_elapsed_ms: Some(500),
786        };
787        let manager = RetryManager::new(config);
788        let attempts = Arc::new(AtomicU32::new(0));
789        let attempts_clone = attempts.clone();
790        let start = time::Instant::now();
791
792        let result = manager
793            .execute_with_retry_with_delay(
794                "test_error_delay",
795                move || {
796                    let attempts = attempts_clone.clone();
797                    async move {
798                        if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
799                            Err(TestError::Retryable("rate limit".to_string()))
800                        } else {
801                            Ok(42)
802                        }
803                    }
804                },
805                should_retry_test_error,
806                |_| Some(Duration::from_millis(200)),
807                create_test_error,
808            )
809            .await;
810
811        assert_eq!(result.unwrap(), 42);
812        assert_eq!(attempts.load(Ordering::SeqCst), 2);
813        #[cfg(not(all(feature = "simulation", madsim)))]
814        assert_eq!(start.elapsed(), Duration::from_millis(200));
815        #[cfg(all(feature = "simulation", madsim))]
816        assert!(
817            start.elapsed() >= Duration::from_millis(200)
818                && start.elapsed() < Duration::from_millis(201)
819        );
820    }
821
822    #[rstest]
823    #[cfg_attr(
824        not(all(feature = "simulation", madsim)),
825        tokio::test(start_paused = true)
826    )]
827    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
828    async fn test_error_retry_delay_over_budget_returns_original_error() {
829        let config = RetryConfig {
830            max_retries: 3,
831            initial_delay_ms: 10,
832            max_delay_ms: 10,
833            backoff_factor: 1.0,
834            jitter_ms: 0,
835            operation_timeout_ms: Some(50),
836            immediate_first: false,
837            max_elapsed_ms: Some(100),
838        };
839        let manager = RetryManager::new(config);
840        let attempts = Arc::new(AtomicU32::new(0));
841        let attempts_clone = attempts.clone();
842
843        let error = manager
844            .execute_with_retry_with_delay(
845                "test_error_delay_budget",
846                move || {
847                    let attempts = attempts_clone.clone();
848                    async move {
849                        attempts.fetch_add(1, Ordering::SeqCst);
850                        Err::<i32, TestError>(TestError::Retryable("rate limit".to_string()))
851                    }
852                },
853                should_retry_test_error,
854                |_| Some(Duration::from_millis(200)),
855                create_test_error,
856            )
857            .await
858            .unwrap_err();
859
860        let TestError::Retryable(message) = error else {
861            panic!("expected original retryable error, was {error}");
862        };
863        assert_eq!(message, "rate limit");
864        assert_eq!(attempts.load(Ordering::SeqCst), 1);
865    }
866
867    #[rstest]
868    #[cfg_attr(
869        not(all(feature = "simulation", madsim)),
870        tokio::test(start_paused = true)
871    )]
872    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
873    async fn test_error_retry_delay_overshoot_returns_original_error() {
874        let config = RetryConfig {
875            max_retries: 3,
876            initial_delay_ms: 10,
877            max_delay_ms: 10,
878            backoff_factor: 1.0,
879            jitter_ms: 0,
880            operation_timeout_ms: Some(20),
881            immediate_first: false,
882            max_elapsed_ms: Some(100),
883        };
884        let manager = RetryManager::new(config);
885        let attempts = Arc::new(AtomicU32::new(0));
886        let attempts_clone = attempts.clone();
887        let attempts_wait = attempts.clone();
888
889        let handle = spawn(async move {
890            manager
891                .execute_with_retry_with_delay(
892                    "test_error_delay_overshoot",
893                    move || {
894                        let attempts = attempts_clone.clone();
895                        async move {
896                            attempts.fetch_add(1, Ordering::SeqCst);
897                            Err::<i32, TestError>(TestError::Retryable("rate limit".to_string()))
898                        }
899                    },
900                    should_retry_test_error,
901                    |_| Some(Duration::from_millis(50)),
902                    create_test_error,
903                )
904                .await
905        });
906
907        yield_until(|| attempts_wait.load(Ordering::SeqCst) == 1).await;
908        advance_clock(Duration::from_millis(100)).await;
909
910        let error = handle.await.unwrap().unwrap_err();
911        let TestError::Retryable(message) = error else {
912            panic!("expected original retryable error, was {error}");
913        };
914        assert_eq!(message, "rate limit");
915        assert_eq!(attempts.load(Ordering::SeqCst), 1);
916    }
917
918    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
919    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
920    async fn test_timeout_path() {
921        let config = RetryConfig {
922            max_retries: 2,
923            initial_delay_ms: 10,
924            max_delay_ms: 50,
925            backoff_factor: 2.0,
926            jitter_ms: 0,
927            operation_timeout_ms: Some(50),
928            immediate_first: false,
929            max_elapsed_ms: None,
930        };
931        let manager = RetryManager::new(config);
932
933        let result = manager
934            .execute_with_retry(
935                "test_timeout",
936                || async {
937                    time::sleep(Duration::from_millis(100)).await;
938                    Ok::<i32, TestError>(42)
939                },
940                should_retry_test_error,
941                create_test_error,
942            )
943            .await;
944
945        let TestError::Timeout(reason) = result.unwrap_err() else {
946            panic!("expected operation timeout");
947        };
948        assert_eq!(reason, RetryError::OperationTimeout { timeout_ms: 50 });
949    }
950
951    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
952    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
953    async fn test_max_elapsed_time_budget() {
954        let config = RetryConfig {
955            max_retries: 10,
956            initial_delay_ms: 50,
957            max_delay_ms: 100,
958            backoff_factor: 2.0,
959            jitter_ms: 0,
960            operation_timeout_ms: None,
961            immediate_first: false,
962            max_elapsed_ms: Some(200),
963        };
964        let manager = RetryManager::new(config);
965
966        let start = time::Instant::now();
967        let result = manager
968            .execute_with_retry(
969                "test_budget",
970                || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
971                should_retry_test_error,
972                create_test_error,
973            )
974            .await;
975
976        let elapsed = start.elapsed();
977        assert!(result.is_err());
978        assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
979        assert!(elapsed.as_millis() >= 150);
980        assert!(elapsed.as_millis() < 1000);
981    }
982
983    #[rstest]
984    #[case::without_operation_timeout(None)]
985    #[case::at_operation_timeout(Some(100))]
986    #[cfg_attr(
987        not(all(feature = "simulation", madsim)),
988        tokio::test(start_paused = true)
989    )]
990    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
991    async fn test_max_elapsed_bounds_in_flight_attempt(#[case] operation_timeout_ms: Option<u64>) {
992        let config = RetryConfig {
993            max_retries: 3,
994            initial_delay_ms: 10,
995            max_delay_ms: 20,
996            backoff_factor: 1.0,
997            jitter_ms: 0,
998            operation_timeout_ms,
999            immediate_first: false,
1000            max_elapsed_ms: Some(100),
1001        };
1002        let manager = RetryManager::new(config);
1003        let attempts = Arc::new(AtomicU32::new(0));
1004        let attempts_clone = Arc::clone(&attempts);
1005        let completed = Arc::new(AtomicBool::new(false));
1006        let completed_clone = Arc::clone(&completed);
1007        let start = time::Instant::now();
1008
1009        let error = manager
1010            .execute_with_retry(
1011                "test_in_flight_budget",
1012                move || {
1013                    let attempts = Arc::clone(&attempts_clone);
1014                    let completed = Arc::clone(&completed_clone);
1015                    async move {
1016                        attempts.fetch_add(1, Ordering::SeqCst);
1017                        time::sleep(Duration::from_secs(1)).await;
1018                        completed.store(true, Ordering::SeqCst);
1019                        Ok::<i32, TestError>(42)
1020                    }
1021                },
1022                should_retry_test_error,
1023                create_test_error,
1024            )
1025            .await
1026            .unwrap_err();
1027
1028        let TestError::Timeout(reason) = error else {
1029            panic!("expected retry budget timeout, was {error}");
1030        };
1031        assert_eq!(
1032            reason,
1033            RetryError::ElapsedBudgetExceeded {
1034                attempt: 1,
1035                max_attempts: 4,
1036                last_error: None,
1037            }
1038        );
1039        assert_eq!(attempts.load(Ordering::SeqCst), 1);
1040        assert!(!completed.load(Ordering::SeqCst));
1041        #[cfg(not(all(feature = "simulation", madsim)))]
1042        assert_eq!(start.elapsed(), Duration::from_millis(100));
1043        #[cfg(all(feature = "simulation", madsim))]
1044        assert!(
1045            start.elapsed() >= Duration::from_millis(100)
1046                && start.elapsed() < Duration::from_millis(101)
1047        );
1048    }
1049
1050    #[cfg_attr(
1051        not(all(feature = "simulation", madsim)),
1052        tokio::test(start_paused = true)
1053    )]
1054    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1055    async fn test_max_elapsed_bounds_later_in_flight_attempt() {
1056        let config = RetryConfig {
1057            max_retries: 3,
1058            initial_delay_ms: 10,
1059            max_delay_ms: 10,
1060            backoff_factor: 1.0,
1061            jitter_ms: 0,
1062            operation_timeout_ms: None,
1063            immediate_first: false,
1064            max_elapsed_ms: Some(100),
1065        };
1066        let manager = RetryManager::new(config);
1067        let attempts = Arc::new(AtomicU32::new(0));
1068        let attempts_clone = Arc::clone(&attempts);
1069
1070        let error = manager
1071            .execute_with_retry(
1072                "test_later_in_flight_budget",
1073                move || {
1074                    let attempt = attempts_clone.fetch_add(1, Ordering::SeqCst);
1075                    async move {
1076                        if attempt == 0 {
1077                            Err::<i32, TestError>(TestError::Retryable("first".to_string()))
1078                        } else {
1079                            std::future::pending().await
1080                        }
1081                    }
1082                },
1083                should_retry_test_error,
1084                create_test_error,
1085            )
1086            .await
1087            .unwrap_err();
1088
1089        let TestError::Timeout(reason) = error else {
1090            panic!("expected retry budget timeout, was {error}");
1091        };
1092        assert_eq!(
1093            reason,
1094            RetryError::ElapsedBudgetExceeded {
1095                attempt: 2,
1096                max_attempts: 4,
1097                last_error: None,
1098            }
1099        );
1100        assert_eq!(attempts.load(Ordering::SeqCst), 2);
1101    }
1102
1103    #[cfg_attr(
1104        not(all(feature = "simulation", madsim)),
1105        tokio::test(start_paused = true)
1106    )]
1107    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1108    async fn test_cancellation_takes_precedence_when_total_deadline_is_ready() {
1109        let config = RetryConfig {
1110            max_retries: 3,
1111            initial_delay_ms: 10,
1112            max_delay_ms: 10,
1113            backoff_factor: 1.0,
1114            jitter_ms: 0,
1115            operation_timeout_ms: None,
1116            immediate_first: false,
1117            max_elapsed_ms: Some(100),
1118        };
1119        let manager = RetryManager::new(config);
1120        let token = CancellationToken::new();
1121        let mut operation = Box::pin(manager.execute_with_retry_with_cancel(
1122            "test_cancellation_at_deadline",
1123            std::future::pending::<Result<i32, TestError>>,
1124            should_retry_test_error,
1125            create_test_error,
1126            &token,
1127        ));
1128
1129        assert!(futures_util::poll!(&mut operation).is_pending());
1130        advance_clock(Duration::from_millis(100)).await;
1131        token.cancel();
1132
1133        let error = operation.await.unwrap_err();
1134        let TestError::Timeout(reason) = error else {
1135            panic!("expected cancellation timeout, was {error}");
1136        };
1137        assert_eq!(reason, RetryError::Canceled);
1138    }
1139
1140    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1141    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1142    async fn test_budget_exceeded_message_format() {
1143        let config = RetryConfig {
1144            max_retries: 5,
1145            initial_delay_ms: 10,
1146            max_delay_ms: 20,
1147            backoff_factor: 1.0,
1148            jitter_ms: 0,
1149            operation_timeout_ms: None,
1150            immediate_first: false,
1151            max_elapsed_ms: Some(35),
1152        };
1153        let manager = RetryManager::new(config);
1154
1155        let result = manager
1156            .execute_with_retry(
1157                "test_budget_msg",
1158                || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
1159                should_retry_test_error,
1160                create_test_error,
1161            )
1162            .await;
1163
1164        assert!(result.is_err());
1165        let error_msg = result.unwrap_err().to_string();
1166
1167        assert!(error_msg.contains("Retry budget exceeded"));
1168        assert!(error_msg.contains("/6)"));
1169
1170        let prefix = "Timeout error: Retry budget exceeded (";
1171        let nums = error_msg
1172            .strip_circumfix(prefix, ")")
1173            .or_else(|| error_msg.strip_circumfix(prefix, "): last error: Retryable error: test"))
1174            .expect("error message should match retry budget format");
1175        let parts: Vec<&str> = nums.split('/').collect();
1176        assert_eq!(parts.len(), 2);
1177        let current: u32 = parts[0].parse().unwrap();
1178        let total: u32 = parts[1].parse().unwrap();
1179
1180        assert_eq!(total, 6, "Total should be max_retries + 1");
1181        assert!(current <= total, "Current attempt should not exceed total");
1182        assert!(current >= 1, "Current attempt should be at least 1");
1183    }
1184
1185    #[cfg_attr(
1186        not(all(feature = "simulation", madsim)),
1187        tokio::test(start_paused = true)
1188    )]
1189    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1190    async fn test_budget_exceeded_edge_cases() {
1191        let config = RetryConfig {
1192            max_retries: 2,
1193            initial_delay_ms: 50,
1194            max_delay_ms: 100,
1195            backoff_factor: 1.0,
1196            jitter_ms: 0,
1197            operation_timeout_ms: None,
1198            immediate_first: false,
1199            max_elapsed_ms: Some(100),
1200        };
1201        let manager = RetryManager::new(config);
1202
1203        let attempt_count = Arc::new(AtomicU32::new(0));
1204        let count_clone = attempt_count.clone();
1205
1206        let handle = spawn(async move {
1207            manager
1208                .execute_with_retry(
1209                    "test_first_attempt",
1210                    move || {
1211                        let count = count_clone.clone();
1212                        async move {
1213                            count.fetch_add(1, Ordering::SeqCst);
1214                            Err::<i32, TestError>(TestError::Retryable("test".to_string()))
1215                        }
1216                    },
1217                    should_retry_test_error,
1218                    create_test_error,
1219                )
1220                .await
1221        });
1222
1223        // Wait for first attempt
1224        yield_until(|| attempt_count.load(Ordering::SeqCst) >= 1).await;
1225
1226        // Advance past budget to trigger check at loop start before second attempt
1227        advance_clock(Duration::from_millis(101)).await;
1228        yield_now().await;
1229
1230        let result = handle.await.unwrap();
1231        assert!(result.is_err());
1232        let error_msg = result.unwrap_err().to_string();
1233
1234        // Budget check happens at loop start, so shows (2/3) = "starting 2nd of 3 attempts"
1235        assert!(
1236            error_msg.contains("(2/3)"),
1237            "Expected (2/3) but got: {error_msg}"
1238        );
1239    }
1240
1241    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1242    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1243    async fn test_budget_exceeded_no_overflow() {
1244        let config = RetryConfig {
1245            max_retries: u32::MAX,
1246            initial_delay_ms: 10,
1247            max_delay_ms: 20,
1248            backoff_factor: 1.0,
1249            jitter_ms: 0,
1250            operation_timeout_ms: None,
1251            immediate_first: false,
1252            max_elapsed_ms: Some(1),
1253        };
1254        let manager = RetryManager::new(config);
1255
1256        let result = manager
1257            .execute_with_retry(
1258                "test_overflow",
1259                || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
1260                should_retry_test_error,
1261                create_test_error,
1262            )
1263            .await;
1264
1265        assert!(result.is_err());
1266        let error_msg = result.unwrap_err().to_string();
1267
1268        // Should saturate at u32::MAX instead of wrapping to 0
1269        assert!(error_msg.contains("Retry budget exceeded"));
1270        assert!(error_msg.contains(&format!("/{}", u32::MAX)));
1271    }
1272
1273    #[rstest]
1274    fn test_http_retry_manager_config() {
1275        let manager = create_http_retry_manager::<TestError>();
1276        assert_eq!(manager.config.max_retries, 3);
1277        assert!(!manager.config.immediate_first);
1278        assert_eq!(manager.config.max_elapsed_ms, Some(180_000));
1279    }
1280
1281    #[rstest]
1282    fn test_websocket_retry_manager_config() {
1283        let manager = create_websocket_retry_manager::<TestError>();
1284        assert_eq!(manager.config.max_retries, 5);
1285        assert!(manager.config.immediate_first);
1286        assert_eq!(manager.config.max_elapsed_ms, Some(120_000));
1287    }
1288
1289    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1290    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1291    async fn test_timeout_respects_retry_predicate() {
1292        let config = RetryConfig {
1293            max_retries: 3,
1294            initial_delay_ms: 10,
1295            max_delay_ms: 50,
1296            backoff_factor: 2.0,
1297            jitter_ms: 0,
1298            operation_timeout_ms: Some(50),
1299            immediate_first: false,
1300            max_elapsed_ms: None,
1301        };
1302        let manager = RetryManager::new(config);
1303
1304        // Test with retry predicate that rejects timeouts
1305        let should_not_retry_timeouts = |error: &TestError| !matches!(error, TestError::Timeout(_));
1306
1307        let result = manager
1308            .execute_with_retry(
1309                "test_timeout_non_retryable",
1310                || async {
1311                    time::sleep(Duration::from_millis(100)).await;
1312                    Ok::<i32, TestError>(42)
1313                },
1314                should_not_retry_timeouts,
1315                create_test_error,
1316            )
1317            .await;
1318
1319        // Should fail immediately without retries since timeout is non-retryable
1320        assert!(result.is_err());
1321        assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1322    }
1323
1324    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1325    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1326    async fn test_timeout_retries_when_predicate_allows() {
1327        let config = RetryConfig {
1328            max_retries: 2,
1329            initial_delay_ms: 10,
1330            max_delay_ms: 50,
1331            backoff_factor: 2.0,
1332            jitter_ms: 0,
1333            operation_timeout_ms: Some(50),
1334            immediate_first: false,
1335            max_elapsed_ms: None,
1336        };
1337        let manager = RetryManager::new(config);
1338
1339        // Test with retry predicate that allows timeouts
1340        let should_retry_timeouts = |error: &TestError| matches!(error, TestError::Timeout(_));
1341
1342        let start = time::Instant::now();
1343        let result = manager
1344            .execute_with_retry(
1345                "test_timeout_retryable",
1346                || async {
1347                    time::sleep(Duration::from_millis(100)).await;
1348                    Ok::<i32, TestError>(42)
1349                },
1350                should_retry_timeouts,
1351                create_test_error,
1352            )
1353            .await;
1354
1355        let elapsed = start.elapsed();
1356
1357        // Should fail after retries (not immediately)
1358        assert!(result.is_err());
1359        assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1360        // Should have taken time for retries (at least 2 timeouts + delays)
1361        assert!(elapsed.as_millis() > 80); // More than just one timeout
1362    }
1363
1364    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1365    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1366    async fn test_successful_retry_after_failures() {
1367        let config = RetryConfig {
1368            max_retries: 3,
1369            initial_delay_ms: 10,
1370            max_delay_ms: 50,
1371            backoff_factor: 2.0,
1372            jitter_ms: 0,
1373            operation_timeout_ms: None,
1374            immediate_first: false,
1375            max_elapsed_ms: None,
1376        };
1377        let manager = RetryManager::new(config);
1378
1379        let attempt_counter = Arc::new(AtomicU32::new(0));
1380        let counter_clone = attempt_counter.clone();
1381
1382        let result = manager
1383            .execute_with_retry(
1384                "test_eventual_success",
1385                move || {
1386                    let counter = counter_clone.clone();
1387                    async move {
1388                        let attempts = counter.fetch_add(1, Ordering::SeqCst);
1389                        if attempts < 2 {
1390                            Err(TestError::Retryable("temporary failure".to_string()))
1391                        } else {
1392                            Ok(42)
1393                        }
1394                    }
1395                },
1396                should_retry_test_error,
1397                create_test_error,
1398            )
1399            .await;
1400
1401        assert_eq!(result.unwrap(), 42);
1402        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1403    }
1404
1405    #[cfg_attr(
1406        not(all(feature = "simulation", madsim)),
1407        tokio::test(start_paused = true)
1408    )]
1409    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1410    async fn test_immediate_first_retry() {
1411        let config = RetryConfig {
1412            max_retries: 2,
1413            initial_delay_ms: 100,
1414            max_delay_ms: 200,
1415            backoff_factor: 2.0,
1416            jitter_ms: 0,
1417            operation_timeout_ms: None,
1418            immediate_first: true,
1419            max_elapsed_ms: None,
1420        };
1421        let manager = RetryManager::new(config);
1422
1423        let attempt_times = Arc::new(parking_lot::Mutex::new(Vec::new()));
1424        let times_clone = attempt_times.clone();
1425        let start = time::Instant::now();
1426
1427        let handle = spawn({
1428            let times_clone = times_clone.clone();
1429            async move {
1430                let _ = manager
1431                    .execute_with_retry(
1432                        "test_immediate",
1433                        move || {
1434                            let times = times_clone.clone();
1435                            async move {
1436                                times.lock().push(start.elapsed());
1437                                Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1438                            }
1439                        },
1440                        should_retry_test_error,
1441                        create_test_error,
1442                    )
1443                    .await;
1444            }
1445        });
1446
1447        // Allow initial attempt and immediate retry to run without advancing time
1448        yield_until(|| attempt_times.lock().len() >= 2).await;
1449
1450        // Advance time for the next backoff interval
1451        advance_clock(Duration::from_millis(100)).await;
1452        yield_now().await;
1453
1454        // Wait for the final retry to be recorded
1455        yield_until(|| attempt_times.lock().len() >= 3).await;
1456
1457        handle.await.unwrap();
1458
1459        let times = attempt_times.lock();
1460        assert_eq!(times.len(), 3); // Initial + 2 retries
1461
1462        // First retry should be immediate (within 1ms tolerance)
1463        assert!(times[1] <= Duration::from_millis(1));
1464        // Second retry should have backoff delay (at least 100ms from start)
1465        assert!(times[2] >= Duration::from_millis(100));
1466        assert!(times[2] <= Duration::from_millis(110));
1467    }
1468
1469    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1470    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1471    async fn test_operation_without_timeout() {
1472        let config = RetryConfig {
1473            max_retries: 2,
1474            initial_delay_ms: 10,
1475            max_delay_ms: 50,
1476            backoff_factor: 2.0,
1477            jitter_ms: 0,
1478            operation_timeout_ms: None, // No timeout
1479            immediate_first: false,
1480            max_elapsed_ms: None,
1481        };
1482        let manager = RetryManager::new(config);
1483
1484        let start = time::Instant::now();
1485        let result = manager
1486            .execute_with_retry(
1487                "test_no_timeout",
1488                || async {
1489                    time::sleep(Duration::from_millis(50)).await;
1490                    Ok::<i32, TestError>(42)
1491                },
1492                should_retry_test_error,
1493                create_test_error,
1494            )
1495            .await;
1496
1497        let elapsed = start.elapsed();
1498        assert_eq!(result.unwrap(), 42);
1499        // Should complete without timing out
1500        assert!(elapsed.as_millis() >= 30);
1501        assert!(elapsed.as_millis() < 200);
1502    }
1503
1504    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1505    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1506    async fn test_zero_retries() {
1507        let config = RetryConfig {
1508            max_retries: 0,
1509            initial_delay_ms: 10,
1510            max_delay_ms: 50,
1511            backoff_factor: 2.0,
1512            jitter_ms: 0,
1513            operation_timeout_ms: None,
1514            immediate_first: false,
1515            max_elapsed_ms: None,
1516        };
1517        let manager = RetryManager::new(config);
1518
1519        let attempt_counter = Arc::new(AtomicU32::new(0));
1520        let counter_clone = attempt_counter.clone();
1521
1522        let result = manager
1523            .execute_with_retry(
1524                "test_no_retries",
1525                move || {
1526                    let counter = counter_clone.clone();
1527                    async move {
1528                        counter.fetch_add(1, Ordering::SeqCst);
1529                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1530                    }
1531                },
1532                should_retry_test_error,
1533                create_test_error,
1534            )
1535            .await;
1536
1537        assert!(result.is_err());
1538        // Should only attempt once (no retries)
1539        assert_eq!(attempt_counter.load(Ordering::SeqCst), 1);
1540    }
1541
1542    #[cfg_attr(
1543        not(all(feature = "simulation", madsim)),
1544        tokio::test(start_paused = true)
1545    )]
1546    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1547    async fn test_jitter_applied() {
1548        let config = RetryConfig {
1549            max_retries: 2,
1550            initial_delay_ms: 50,
1551            max_delay_ms: 100,
1552            backoff_factor: 2.0,
1553            jitter_ms: 50, // Significant jitter
1554            operation_timeout_ms: None,
1555            immediate_first: false,
1556            max_elapsed_ms: None,
1557        };
1558        let manager = RetryManager::new(config);
1559
1560        let delays = Arc::new(parking_lot::Mutex::new(Vec::new()));
1561        let delays_clone = delays.clone();
1562        let last_time = Arc::new(parking_lot::Mutex::new(time::Instant::now()));
1563        let last_time_clone = last_time.clone();
1564
1565        let handle = spawn({
1566            let delays_clone = delays_clone.clone();
1567            async move {
1568                let _ = manager
1569                    .execute_with_retry(
1570                        "test_jitter",
1571                        move || {
1572                            let delays = delays_clone.clone();
1573                            let last_time = last_time_clone.clone();
1574                            async move {
1575                                let now = time::Instant::now();
1576                                let delay = {
1577                                    let mut last = last_time.lock();
1578                                    let d = now.duration_since(*last);
1579                                    *last = now;
1580                                    d
1581                                };
1582                                delays.lock().push(delay);
1583                                Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1584                            }
1585                        },
1586                        should_retry_test_error,
1587                        create_test_error,
1588                    )
1589                    .await;
1590            }
1591        });
1592
1593        yield_until(|| !delays.lock().is_empty()).await;
1594        advance_until(|| delays.lock().len() >= 2).await;
1595        advance_until(|| delays.lock().len() >= 3).await;
1596
1597        handle.await.unwrap();
1598
1599        let delays = delays.lock();
1600        // Skip the first delay (initial attempt)
1601        for delay in delays.iter().skip(1) {
1602            // Each delay should be at least the base delay (50ms for first retry)
1603            assert!(delay.as_millis() >= 50);
1604            // But no more than base + jitter (allow small tolerance for step advance)
1605            assert!(delay.as_millis() <= 151);
1606        }
1607    }
1608
1609    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1610    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1611    async fn test_max_elapsed_stops_early() {
1612        let config = RetryConfig {
1613            max_retries: 100, // Very high retry count
1614            initial_delay_ms: 50,
1615            max_delay_ms: 100,
1616            backoff_factor: 1.5,
1617            jitter_ms: 0,
1618            operation_timeout_ms: None,
1619            immediate_first: false,
1620            max_elapsed_ms: Some(150), // Should stop after ~3 attempts
1621        };
1622        let manager = RetryManager::new(config);
1623
1624        let attempt_counter = Arc::new(AtomicU32::new(0));
1625        let counter_clone = attempt_counter.clone();
1626
1627        let start = time::Instant::now();
1628        let result = manager
1629            .execute_with_retry(
1630                "test_elapsed_limit",
1631                move || {
1632                    let counter = counter_clone.clone();
1633                    async move {
1634                        counter.fetch_add(1, Ordering::SeqCst);
1635                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1636                    }
1637                },
1638                should_retry_test_error,
1639                create_test_error,
1640            )
1641            .await;
1642
1643        let elapsed = start.elapsed();
1644        assert!(result.is_err());
1645        assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1646
1647        // Should have stopped due to time limit, not retry count
1648        let attempts = attempt_counter.load(Ordering::SeqCst);
1649        assert!(attempts < 10); // Much less than max_retries
1650        assert!(elapsed.as_millis() >= 100);
1651    }
1652
1653    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1654    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1655    async fn test_mixed_errors_retry_behavior() {
1656        let config = RetryConfig {
1657            max_retries: 5,
1658            initial_delay_ms: 10,
1659            max_delay_ms: 50,
1660            backoff_factor: 2.0,
1661            jitter_ms: 0,
1662            operation_timeout_ms: None,
1663            immediate_first: false,
1664            max_elapsed_ms: None,
1665        };
1666        let manager = RetryManager::new(config);
1667
1668        let attempt_counter = Arc::new(AtomicU32::new(0));
1669        let counter_clone = attempt_counter.clone();
1670
1671        let result = manager
1672            .execute_with_retry(
1673                "test_mixed_errors",
1674                move || {
1675                    let counter = counter_clone.clone();
1676                    async move {
1677                        let attempts = counter.fetch_add(1, Ordering::SeqCst);
1678                        match attempts {
1679                            0 => Err(TestError::Retryable("retry 1".to_string())),
1680                            1 => Err(TestError::Retryable("retry 2".to_string())),
1681                            2 => Err(TestError::NonRetryable("stop here".to_string())),
1682                            _ => Ok(42),
1683                        }
1684                    }
1685                },
1686                should_retry_test_error,
1687                create_test_error,
1688            )
1689            .await;
1690
1691        assert!(result.is_err());
1692        assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
1693        // Should stop at the non-retryable error
1694        assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1695    }
1696
1697    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1698    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1699    async fn test_cancellation_during_retry_delay() {
1700        use tokio_util::sync::CancellationToken;
1701
1702        let config = RetryConfig {
1703            max_retries: 10,
1704            initial_delay_ms: 500, // Long delay to ensure cancellation happens during sleep
1705            max_delay_ms: 1000,
1706            backoff_factor: 2.0,
1707            jitter_ms: 0,
1708            operation_timeout_ms: None,
1709            immediate_first: false,
1710            max_elapsed_ms: None,
1711        };
1712        let manager = RetryManager::new(config);
1713
1714        let token = CancellationToken::new();
1715        let token_clone = token.clone();
1716
1717        // Cancel after a short delay
1718        spawn(async move {
1719            time::sleep(Duration::from_millis(100)).await;
1720            token_clone.cancel();
1721        });
1722
1723        let attempt_counter = Arc::new(AtomicU32::new(0));
1724        let counter_clone = attempt_counter.clone();
1725
1726        let start = time::Instant::now();
1727        let result = manager
1728            .execute_with_retry_with_cancel(
1729                "test_cancellation",
1730                move || {
1731                    let counter = counter_clone.clone();
1732                    async move {
1733                        counter.fetch_add(1, Ordering::SeqCst);
1734                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1735                    }
1736                },
1737                should_retry_test_error,
1738                create_test_error,
1739                &token,
1740            )
1741            .await;
1742
1743        let elapsed = start.elapsed();
1744
1745        // Should be canceled quickly
1746        assert!(result.is_err());
1747        let error_msg = format!("{}", result.unwrap_err());
1748        assert!(error_msg.contains("canceled"));
1749
1750        // Should not have taken the full delay time
1751        assert!(elapsed.as_millis() < 600);
1752
1753        // Should have made at least one attempt
1754        let attempts = attempt_counter.load(Ordering::SeqCst);
1755        assert!(attempts >= 1);
1756    }
1757
1758    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1759    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1760    async fn test_cancellation_during_operation_execution() {
1761        use tokio_util::sync::CancellationToken;
1762
1763        let config = RetryConfig {
1764            max_retries: 5,
1765            initial_delay_ms: 50,
1766            max_delay_ms: 100,
1767            backoff_factor: 2.0,
1768            jitter_ms: 0,
1769            operation_timeout_ms: None,
1770            immediate_first: false,
1771            max_elapsed_ms: None,
1772        };
1773        let manager = RetryManager::new(config);
1774
1775        let token = CancellationToken::new();
1776        let token_clone = token.clone();
1777
1778        // Cancel after a short delay
1779        spawn(async move {
1780            time::sleep(Duration::from_millis(50)).await;
1781            token_clone.cancel();
1782        });
1783
1784        let start = time::Instant::now();
1785        let result = manager
1786            .execute_with_retry_with_cancel(
1787                "test_cancellation_during_op",
1788                || async {
1789                    // Long-running operation
1790                    time::sleep(Duration::from_millis(200)).await;
1791                    Ok::<i32, TestError>(42)
1792                },
1793                should_retry_test_error,
1794                create_test_error,
1795                &token,
1796            )
1797            .await;
1798
1799        let elapsed = start.elapsed();
1800
1801        // Should be canceled during the operation
1802        assert!(result.is_err());
1803        let error_msg = format!("{}", result.unwrap_err());
1804        assert!(error_msg.contains("canceled"));
1805
1806        // Should not have completed the long operation
1807        assert!(elapsed.as_millis() < 250);
1808    }
1809
1810    #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1811    #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1812    async fn test_cancellation_error_message() {
1813        use tokio_util::sync::CancellationToken;
1814
1815        let config = RetryConfig::default();
1816        let manager = RetryManager::new(config);
1817
1818        let token = CancellationToken::new();
1819        token.cancel(); // Pre-cancel for immediate cancellation
1820
1821        let result = manager
1822            .execute_with_retry_with_cancel(
1823                "test_operation",
1824                || async { Ok::<i32, TestError>(42) },
1825                should_retry_test_error,
1826                create_test_error,
1827                &token,
1828            )
1829            .await;
1830
1831        assert!(result.is_err());
1832        let error_msg = format!("{}", result.unwrap_err());
1833        assert!(error_msg.contains("canceled"));
1834    }
1835}
1836
1837#[cfg(test)]
1838mod proptest_tests {
1839    use std::sync::{
1840        Arc,
1841        atomic::{AtomicU32, Ordering},
1842    };
1843
1844    #[cfg(all(feature = "simulation", madsim))]
1845    use madsim::task::spawn;
1846    use proptest::prelude::*;
1847    // Import rstest attribute macro used within proptest! tests
1848    use rstest::rstest;
1849    #[cfg(not(all(feature = "simulation", madsim)))]
1850    use tokio::task::spawn;
1851
1852    #[cfg(not(all(feature = "simulation", madsim)))]
1853    use super::tests::{advance_until, yield_until};
1854    use super::{test_utils::*, tests::advance_clock, *};
1855    use crate::dst::time;
1856
1857    // Each proptest case constructs a runtime to drive the manager via
1858    // `block_on`. Under tokio, that runtime is paused so virtual sleeps
1859    // auto-advance; under madsim, the runtime is the deterministic sim
1860    // runtime, which also runs in virtual time. Both expose `block_on`.
1861    #[cfg(all(feature = "simulation", madsim))]
1862    fn build_paused_runtime() -> madsim::runtime::Runtime {
1863        madsim::runtime::Runtime::new()
1864    }
1865
1866    #[cfg(not(all(feature = "simulation", madsim)))]
1867    fn build_paused_runtime() -> tokio::runtime::Runtime {
1868        tokio::runtime::Builder::new_current_thread()
1869            .enable_time()
1870            .start_paused(true)
1871            .build()
1872            .unwrap()
1873    }
1874
1875    proptest! {
1876        #[rstest]
1877        fn test_retry_config_valid_ranges(
1878            max_retries in 0u32..100,
1879            initial_delay_ms in 1u64..10_000,
1880            max_delay_ms in 1u64..60_000,
1881            backoff_factor in 1.0f64..10.0,
1882            jitter_ms in 0u64..1_000,
1883            operation_timeout_ms in prop::option::of(1u64..120_000),
1884            immediate_first in any::<bool>(),
1885            max_elapsed_ms in prop::option::of(1u64..300_000)
1886        ) {
1887            // Ensure max_delay >= initial_delay for valid config
1888            let max_delay_ms = max_delay_ms.max(initial_delay_ms);
1889
1890            let config = RetryConfig {
1891                max_retries,
1892                initial_delay_ms,
1893                max_delay_ms,
1894                backoff_factor,
1895                jitter_ms,
1896                operation_timeout_ms,
1897                immediate_first,
1898                max_elapsed_ms,
1899            };
1900
1901            // Should always be able to create a RetryManager with valid config
1902            let _manager = RetryManager::<std::io::Error>::new(config);
1903        }
1904
1905        #[rstest]
1906        fn test_retry_attempts_bounded(
1907            max_retries in 0u32..5,
1908            initial_delay_ms in 1u64..10,
1909            backoff_factor in 1.0f64..2.0,
1910        ) {
1911            let rt = build_paused_runtime();
1912
1913            let config = RetryConfig {
1914                max_retries,
1915                initial_delay_ms,
1916                max_delay_ms: initial_delay_ms * 2,
1917                backoff_factor,
1918                jitter_ms: 0,
1919                operation_timeout_ms: None,
1920                immediate_first: false,
1921                max_elapsed_ms: None,
1922            };
1923
1924            let manager = RetryManager::new(config);
1925            let attempt_counter = Arc::new(AtomicU32::new(0));
1926            let counter_clone = attempt_counter.clone();
1927
1928            let _result = rt.block_on(manager.execute_with_retry(
1929                "prop_test",
1930                move || {
1931                    let counter = counter_clone.clone();
1932                    async move {
1933                        counter.fetch_add(1, Ordering::SeqCst);
1934                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1935                    }
1936                },
1937                |e: &TestError| matches!(e, TestError::Retryable(_)),
1938                TestError::Timeout,
1939            ));
1940
1941            let attempts = attempt_counter.load(Ordering::SeqCst);
1942            // Total attempts should be 1 (initial) + max_retries
1943            prop_assert_eq!(attempts, max_retries + 1);
1944        }
1945
1946        #[rstest]
1947        fn test_error_retry_delay_obeys_selection_and_budget(
1948            backoff_ms in 1u64..500,
1949            minimum_ms in 0u64..1_000,
1950            operation_timeout_ms in 1u64..50,
1951        ) {
1952            let rt = build_paused_runtime();
1953            let selected_ms = backoff_ms.max(minimum_ms);
1954            let config = |max_elapsed_ms| RetryConfig {
1955                max_retries: 1,
1956                initial_delay_ms: backoff_ms,
1957                max_delay_ms: backoff_ms,
1958                backoff_factor: 1.0,
1959                jitter_ms: 0,
1960                operation_timeout_ms: Some(operation_timeout_ms),
1961                immediate_first: false,
1962                max_elapsed_ms: Some(max_elapsed_ms),
1963            };
1964            let minimum_delay = Duration::from_millis(minimum_ms);
1965
1966            let manager = RetryManager::new(config(selected_ms + 1));
1967            let attempts = Arc::new(AtomicU32::new(0));
1968            let attempts_clone = attempts.clone();
1969            let (result, elapsed) = rt.block_on(async {
1970                let start = time::Instant::now();
1971                let result = manager
1972                    .execute_with_retry_with_delay(
1973                        "prop_error_delay_selection",
1974                        move || {
1975                            let attempts = attempts_clone.clone();
1976                            async move {
1977                                if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
1978                                    Err(TestError::Retryable("rate limit".to_string()))
1979                                } else {
1980                                    Ok(42)
1981                                }
1982                            }
1983                        },
1984                        should_retry_test_error,
1985                        |_| Some(minimum_delay),
1986                        create_test_error,
1987                    )
1988                    .await;
1989                (result, start.elapsed())
1990            });
1991
1992            prop_assert_eq!(result.unwrap(), 42);
1993            prop_assert_eq!(attempts.load(Ordering::SeqCst), 2);
1994            let selected = Duration::from_millis(selected_ms);
1995            #[cfg(all(feature = "simulation", madsim))]
1996            {
1997                prop_assert!(elapsed >= selected);
1998                prop_assert!(elapsed < selected + Duration::from_millis(1));
1999            }
2000            #[cfg(not(all(feature = "simulation", madsim)))]
2001            prop_assert_eq!(elapsed, selected);
2002
2003            let manager = RetryManager::new(config(selected_ms));
2004            let attempts = Arc::new(AtomicU32::new(0));
2005            let attempts_clone = attempts.clone();
2006            let (error, elapsed) = rt.block_on(async {
2007                let start = time::Instant::now();
2008                let error = manager
2009                    .execute_with_retry_with_delay(
2010                        "prop_error_delay_budget",
2011                        move || {
2012                            let attempts = attempts_clone.clone();
2013                            async move {
2014                                attempts.fetch_add(1, Ordering::SeqCst);
2015                                Err::<i32, TestError>(TestError::Retryable(
2016                                    "rate limit".to_string(),
2017                                ))
2018                            }
2019                        },
2020                        should_retry_test_error,
2021                        |_| Some(minimum_delay),
2022                        create_test_error,
2023                    )
2024                    .await
2025                    .unwrap_err();
2026                (error, start.elapsed())
2027            });
2028
2029            match error {
2030                TestError::Retryable(message) => prop_assert_eq!(message, "rate limit"),
2031                error => prop_assert!(false, "expected original retryable error, was {error}"),
2032            }
2033            prop_assert_eq!(attempts.load(Ordering::SeqCst), 1);
2034            prop_assert_eq!(elapsed, Duration::ZERO);
2035        }
2036
2037        #[rstest]
2038        fn test_timeout_always_respected(
2039            timeout_ms in 10u64..50,
2040            operation_delay_ms in 60u64..100,
2041        ) {
2042            let rt = build_paused_runtime();
2043
2044            let config = RetryConfig {
2045                max_retries: 0, // No retries to isolate timeout behavior
2046                initial_delay_ms: 10,
2047                max_delay_ms: 100,
2048                backoff_factor: 2.0,
2049                jitter_ms: 0,
2050                operation_timeout_ms: Some(timeout_ms),
2051                immediate_first: false,
2052                max_elapsed_ms: None,
2053            };
2054
2055            let manager = RetryManager::new(config);
2056
2057            let result = rt.block_on(async {
2058                let operation_future = manager.execute_with_retry(
2059                    "timeout_test",
2060                    move || async move {
2061                        time::sleep(Duration::from_millis(operation_delay_ms)).await;
2062                        Ok::<i32, TestError>(42)
2063                    },
2064                    |_: &TestError| true,
2065                    TestError::Timeout,
2066                );
2067
2068                // Advance time to trigger timeout
2069                advance_clock(Duration::from_millis(timeout_ms + 10)).await;
2070                operation_future.await
2071            });
2072
2073            // Operation should timeout
2074            prop_assert!(result.is_err());
2075            prop_assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
2076        }
2077
2078        #[rstest]
2079        fn test_max_elapsed_always_respected(
2080            max_elapsed_ms in 20u64..50,
2081            delay_per_retry in 15u64..30,
2082            max_retries in 10u32..20,
2083        ) {
2084            let rt = build_paused_runtime();
2085
2086            // Set up config where we would exceed max_elapsed_ms before max_retries
2087            let config = RetryConfig {
2088                max_retries,
2089                initial_delay_ms: delay_per_retry,
2090                max_delay_ms: delay_per_retry * 2,
2091                backoff_factor: 1.0, // No backoff to make timing predictable
2092                jitter_ms: 0,
2093                operation_timeout_ms: None,
2094                immediate_first: false,
2095                max_elapsed_ms: Some(max_elapsed_ms),
2096            };
2097
2098            let manager = RetryManager::new(config);
2099            let attempt_counter = Arc::new(AtomicU32::new(0));
2100            let counter_clone = attempt_counter.clone();
2101
2102            let result = rt.block_on(async {
2103                let operation_future = manager.execute_with_retry(
2104                    "elapsed_test",
2105                    move || {
2106                        let counter = counter_clone.clone();
2107                        async move {
2108                            counter.fetch_add(1, Ordering::SeqCst);
2109                            Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2110                        }
2111                    },
2112                    |e: &TestError| matches!(e, TestError::Retryable(_)),
2113                    TestError::Timeout,
2114                );
2115
2116                // Advance time past max_elapsed_ms
2117                advance_clock(Duration::from_millis(max_elapsed_ms + delay_per_retry)).await;
2118                operation_future.await
2119            });
2120
2121            let attempts = attempt_counter.load(Ordering::SeqCst);
2122
2123            // Should have failed with timeout error
2124            prop_assert!(result.is_err());
2125            prop_assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
2126
2127            // Should have stopped before exhausting all retries
2128            prop_assert!(attempts <= max_retries + 1);
2129        }
2130
2131        #[rstest]
2132        fn test_jitter_bounds(
2133            jitter_ms in 0u64..20,
2134            base_delay_ms in 10u64..30,
2135        ) {
2136            let rt = build_paused_runtime();
2137
2138            let config = RetryConfig {
2139                max_retries: 2,
2140                initial_delay_ms: base_delay_ms,
2141                max_delay_ms: base_delay_ms * 2,
2142                backoff_factor: 1.0, // No backoff to isolate jitter
2143                jitter_ms,
2144                operation_timeout_ms: None,
2145                immediate_first: false,
2146                max_elapsed_ms: None,
2147            };
2148
2149            let manager = RetryManager::new(config);
2150            let attempt_times = Arc::new(parking_lot::Mutex::new(Vec::new()));
2151            let attempt_times_for_block = attempt_times.clone();
2152
2153            rt.block_on(async move {
2154                #[cfg(not(all(feature = "simulation", madsim)))]
2155                let attempt_times_for_wait = attempt_times_for_block.clone();
2156                let handle = spawn({
2157                    let attempt_times_for_task = attempt_times_for_block.clone();
2158                    let manager = manager;
2159                    async move {
2160                        let start_time = time::Instant::now();
2161                        let _ = manager
2162                            .execute_with_retry(
2163                                "jitter_test",
2164                                move || {
2165                                    let attempt_times_inner = attempt_times_for_task.clone();
2166                                    async move {
2167                                        attempt_times_inner
2168                                            .lock()
2169                                            .push(start_time.elapsed());
2170                                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2171                                    }
2172                                },
2173                                |e: &TestError| matches!(e, TestError::Retryable(_)),
2174                                TestError::Timeout,
2175                            )
2176                            .await;
2177                    }
2178                });
2179
2180                // Under tokio paused clock, drive virtual time forward in 1ms
2181                // ticks to release the manager's sleeps; under madsim the
2182                // runtime auto-advances when all tasks block on virtual time,
2183                // so awaiting the handle is enough and yields exact timings.
2184                #[cfg(not(all(feature = "simulation", madsim)))]
2185                {
2186                    yield_until(|| !attempt_times_for_wait.lock().is_empty()).await;
2187                    advance_until(|| attempt_times_for_wait.lock().len() >= 2).await;
2188                    advance_until(|| attempt_times_for_wait.lock().len() >= 3).await;
2189                }
2190
2191                handle.await.unwrap();
2192            });
2193
2194            let times = attempt_times.lock();
2195
2196            // We expect at least 2 attempts total (initial + at least 1 retry)
2197            prop_assert!(times.len() >= 2);
2198
2199            // First attempt should be immediate (no delay)
2200            prop_assert!(times[0].as_millis() < 5);
2201
2202            // Check subsequent retries have appropriate delays
2203            for i in 1..times.len() {
2204                let delay_from_previous = if i == 1 {
2205                    times[i].checked_sub(times[0]).unwrap()
2206                } else {
2207                    times[i].checked_sub(times[i - 1]).unwrap()
2208                };
2209
2210                // The delay floor is min(base, max - jitter): near the cap the
2211                // jittered base is lowered so the spread survives saturation
2212                let floor = base_delay_ms.min((base_delay_ms * 2).saturating_sub(jitter_ms));
2213                prop_assert!(
2214                    delay_from_previous.as_millis() >= u128::from(floor),
2215                    "Retry {} delay {}ms is less than floor {}ms",
2216                    i, delay_from_previous.as_millis(), floor
2217                );
2218
2219                // Delay should be at most base_delay + jitter
2220                prop_assert!(
2221                    delay_from_previous.as_millis() <= u128::from(base_delay_ms + jitter_ms + 1),
2222                    "Retry {} delay {}ms exceeds base {} + jitter {}",
2223                    i, delay_from_previous.as_millis(), base_delay_ms, jitter_ms
2224                );
2225            }
2226        }
2227
2228        #[rstest]
2229        fn test_immediate_first_property(
2230            immediate_first in any::<bool>(),
2231            initial_delay_ms in 10u64..30,
2232        ) {
2233            let rt = build_paused_runtime();
2234
2235            let config = RetryConfig {
2236                max_retries: 2,
2237                initial_delay_ms,
2238                max_delay_ms: initial_delay_ms * 2,
2239                backoff_factor: 2.0,
2240                jitter_ms: 0,
2241                operation_timeout_ms: None,
2242                immediate_first,
2243                max_elapsed_ms: None,
2244            };
2245
2246            let manager = RetryManager::new(config);
2247            let attempt_times = Arc::new(parking_lot::Mutex::new(Vec::new()));
2248            let attempt_times_for_block = attempt_times.clone();
2249
2250            rt.block_on(async move {
2251                #[cfg(not(all(feature = "simulation", madsim)))]
2252                let attempt_times_for_wait = attempt_times_for_block.clone();
2253                let handle = spawn({
2254                    let attempt_times_for_task = attempt_times_for_block.clone();
2255                    let manager = manager;
2256                    async move {
2257                        let start = time::Instant::now();
2258                        let _ = manager
2259                            .execute_with_retry(
2260                                "immediate_test",
2261                                move || {
2262                                    let attempt_times_inner = attempt_times_for_task.clone();
2263                                    async move {
2264                                        let elapsed = start.elapsed();
2265                                        attempt_times_inner.lock().push(elapsed);
2266                                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2267                                    }
2268                                },
2269                                |e: &TestError| matches!(e, TestError::Retryable(_)),
2270                                TestError::Timeout,
2271                            )
2272                            .await;
2273                    }
2274                });
2275
2276                // See test_jitter_bounds: madsim auto-advances virtual time
2277                // when all tasks block on it, so awaiting the handle suffices
2278                // and avoids the 1ms-tick driver's added scheduler overhead.
2279                #[cfg(not(all(feature = "simulation", madsim)))]
2280                {
2281                    yield_until(|| !attempt_times_for_wait.lock().is_empty()).await;
2282                    advance_until(|| attempt_times_for_wait.lock().len() >= 2).await;
2283                    advance_until(|| attempt_times_for_wait.lock().len() >= 3).await;
2284                }
2285
2286                handle.await.unwrap();
2287            });
2288
2289            let times = attempt_times.lock();
2290            prop_assert!(times.len() >= 2);
2291
2292            if immediate_first {
2293                // First retry should be immediate
2294                prop_assert!(times[1].as_millis() < 20,
2295                    "With immediate_first=true, first retry took {}ms",
2296                    times[1].as_millis());
2297            } else {
2298                // First retry should have delay
2299                prop_assert!(times[1].as_millis() >= u128::from(initial_delay_ms - 1),
2300                    "With immediate_first=false, first retry was too fast: {}ms",
2301                    times[1].as_millis());
2302            }
2303        }
2304
2305        #[rstest]
2306        fn test_non_retryable_stops_immediately(
2307            attempt_before_non_retryable in 0usize..3,
2308            max_retries in 3u32..5,
2309        ) {
2310            let rt = build_paused_runtime();
2311
2312            let config = RetryConfig {
2313                max_retries,
2314                initial_delay_ms: 10,
2315                max_delay_ms: 100,
2316                backoff_factor: 2.0,
2317                jitter_ms: 0,
2318                operation_timeout_ms: None,
2319                immediate_first: false,
2320                max_elapsed_ms: None,
2321            };
2322
2323            let manager = RetryManager::new(config);
2324            let attempt_counter = Arc::new(AtomicU32::new(0));
2325            let counter_clone = attempt_counter.clone();
2326
2327            let result: Result<i32, TestError> = rt.block_on(manager.execute_with_retry(
2328                "non_retryable_test",
2329                move || {
2330                    let counter = counter_clone.clone();
2331                    async move {
2332                        let attempts = counter.fetch_add(1, Ordering::SeqCst) as usize;
2333                        if attempts == attempt_before_non_retryable {
2334                            Err(TestError::NonRetryable("stop".to_string()))
2335                        } else {
2336                            Err(TestError::Retryable("retry".to_string()))
2337                        }
2338                    }
2339                },
2340                |e: &TestError| matches!(e, TestError::Retryable(_)),
2341                TestError::Timeout,
2342            ));
2343
2344            let attempts = attempt_counter.load(Ordering::SeqCst) as usize;
2345
2346            prop_assert!(result.is_err());
2347            prop_assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
2348            // Should stop exactly when non-retryable error occurs
2349            prop_assert_eq!(attempts, attempt_before_non_retryable + 1);
2350        }
2351
2352        #[rstest]
2353        fn test_cancellation_stops_immediately(
2354            cancel_after_ms in 10u64..100,
2355            initial_delay_ms in 200u64..500,
2356        ) {
2357            use tokio_util::sync::CancellationToken;
2358
2359            let rt = build_paused_runtime();
2360
2361            let config = RetryConfig {
2362                max_retries: 10,
2363                initial_delay_ms,
2364                max_delay_ms: initial_delay_ms * 2,
2365                backoff_factor: 2.0,
2366                jitter_ms: 0,
2367                operation_timeout_ms: None,
2368                immediate_first: false,
2369                max_elapsed_ms: None,
2370            };
2371
2372            let manager = RetryManager::new(config);
2373            let token = CancellationToken::new();
2374            let token_clone = token.clone();
2375
2376            let result: Result<i32, TestError> = rt.block_on(async {
2377                // Spawn cancellation task
2378                spawn(async move {
2379                    time::sleep(Duration::from_millis(cancel_after_ms)).await;
2380                    token_clone.cancel();
2381                });
2382
2383                let operation_future = manager.execute_with_retry_with_cancel(
2384                    "cancellation_test",
2385                    || async {
2386                        Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2387                    },
2388                    |e: &TestError| matches!(e, TestError::Retryable(_)),
2389                    create_test_error,
2390                    &token,
2391                );
2392
2393                // Advance time to trigger cancellation
2394                advance_clock(Duration::from_millis(cancel_after_ms + 10)).await;
2395                operation_future.await
2396            });
2397
2398            // Should be canceled
2399            prop_assert!(result.is_err());
2400            let error_msg = format!("{}", result.unwrap_err());
2401            prop_assert!(error_msg.contains("canceled"));
2402        }
2403
2404        #[rstest]
2405        fn test_budget_clamp_prevents_overshoot(
2406            max_elapsed_ms in 10u64..30,
2407            delay_per_retry in 30u64..50,
2408        ) {
2409            let rt = build_paused_runtime();
2410
2411            // Configure so that first retry delay would exceed budget
2412            let config = RetryConfig {
2413                max_retries: 5,
2414                initial_delay_ms: delay_per_retry,
2415                max_delay_ms: delay_per_retry * 2,
2416                backoff_factor: 1.0,
2417                jitter_ms: 0,
2418                operation_timeout_ms: None,
2419                immediate_first: false,
2420                max_elapsed_ms: Some(max_elapsed_ms),
2421            };
2422
2423            let manager = RetryManager::new(config);
2424            let attempts = Arc::new(AtomicU32::new(0));
2425            let attempts_for_operation = Arc::clone(&attempts);
2426
2427            let (result, elapsed) = rt.block_on(async {
2428                let started_at = time::Instant::now();
2429                let result = manager.execute_with_retry(
2430                    "budget_clamp_test",
2431                    move || {
2432                        let attempts = Arc::clone(&attempts_for_operation);
2433                        async move {
2434                            attempts.fetch_add(1, Ordering::SeqCst);
2435                            Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2436                        }
2437                    },
2438                    |e: &TestError| matches!(e, TestError::Retryable(_)),
2439                    create_test_error,
2440                ).await;
2441                (result, started_at.elapsed())
2442            });
2443
2444            assert!(matches!(
2445                result,
2446                Err(TestError::Timeout(RetryError::ElapsedBudgetExceeded {
2447                    attempt: 2,
2448                    max_attempts: 6,
2449                    last_error: None,
2450                }))
2451            ));
2452            assert_eq!(attempts.load(Ordering::SeqCst), 1);
2453            #[cfg(not(all(feature = "simulation", madsim)))]
2454            assert_eq!(elapsed, Duration::from_millis(max_elapsed_ms));
2455            #[cfg(all(feature = "simulation", madsim))]
2456            assert!(
2457                elapsed >= Duration::from_millis(max_elapsed_ms)
2458                    && elapsed < Duration::from_millis(max_elapsed_ms + 1)
2459            );
2460        }
2461
2462        #[rstest]
2463        fn test_success_on_kth_attempt(
2464            k in 1usize..5,
2465            initial_delay_ms in 5u64..20,
2466        ) {
2467            let rt = build_paused_runtime();
2468
2469            let config = RetryConfig {
2470                max_retries: 10, // More than k
2471                initial_delay_ms,
2472                max_delay_ms: initial_delay_ms * 4,
2473                backoff_factor: 2.0,
2474                jitter_ms: 0,
2475                operation_timeout_ms: None,
2476                immediate_first: false,
2477                max_elapsed_ms: None,
2478            };
2479
2480            let manager = RetryManager::new(config);
2481            let attempt_counter = Arc::new(AtomicU32::new(0));
2482            let counter_clone = attempt_counter.clone();
2483            let target_k = k;
2484
2485            let (result, _elapsed) = rt.block_on(async {
2486                let start = time::Instant::now();
2487
2488                let operation_future = manager.execute_with_retry(
2489                    "kth_attempt_test",
2490                    move || {
2491                        let counter = counter_clone.clone();
2492                        async move {
2493                            let attempt = counter.fetch_add(1, Ordering::SeqCst) as usize;
2494                            if attempt + 1 == target_k {
2495                                Ok(42)
2496                            } else {
2497                                Err(TestError::Retryable("retry".to_string()))
2498                            }
2499                        }
2500                    },
2501                    |e: &TestError| matches!(e, TestError::Retryable(_)),
2502                    create_test_error,
2503                );
2504
2505                // Advance time to allow enough retries
2506                for _ in 0..k {
2507                    advance_clock(Duration::from_millis(initial_delay_ms * 4)).await;
2508                }
2509
2510                let result = operation_future.await;
2511                let elapsed = start.elapsed();
2512
2513                (result, elapsed)
2514            });
2515
2516            let attempts = attempt_counter.load(Ordering::SeqCst) as usize;
2517
2518            // Using paused Tokio time (start_paused + advance); assert behavior only (no wall-clock timing)
2519            prop_assert!(result.is_ok());
2520            prop_assert_eq!(result.unwrap(), 42);
2521            prop_assert_eq!(attempts, k);
2522        }
2523    }
2524}