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