1use std::{collections::VecDeque, pin::pin, sync::atomic::AtomicU8, time::Duration};
24
25use nautilus_core::correctness::{check_in_range_inclusive_f64, check_predicate_true};
26use rand::RngExt;
27
28use crate::{dst, mode::ConnectionMode};
29
30pub(crate) const RECONNECT_STABILITY_THRESHOLD: Duration = Duration::from_secs(10);
32
33pub(crate) const RECONNECT_MIN_DELAY: Duration = Duration::from_secs(1);
38
39pub(crate) const RECONNECT_MIN_DELAY_ATTEMPTS: usize = 3;
42
43pub(crate) const RECONNECT_MIN_DELAY_WINDOW: Duration = Duration::from_mins(2);
48
49#[derive(Debug, Default)]
57pub(crate) struct ReconnectThrottle {
58 recent_attempts: VecDeque<dst::time::Instant>,
59}
60
61impl ReconnectThrottle {
62 pub(crate) fn gated_delay(&mut self, backoff_delay: Duration) -> Duration {
66 self.prune_expired();
67
68 if self.recent_attempts.len() >= RECONNECT_MIN_DELAY_ATTEMPTS {
69 backoff_delay.max(RECONNECT_MIN_DELAY)
70 } else {
71 backoff_delay
72 }
73 }
74
75 pub(crate) fn record_attempt(&mut self) {
77 self.prune_expired();
78 self.recent_attempts.push_back(dst::time::Instant::now());
79 }
80
81 fn prune_expired(&mut self) {
82 while self
83 .recent_attempts
84 .front()
85 .is_some_and(|oldest| oldest.elapsed() > RECONNECT_MIN_DELAY_WINDOW)
86 {
87 self.recent_attempts.pop_front();
88 }
89 }
90}
91
92#[derive(Clone, Debug)]
93pub struct ExponentialBackoff {
94 delay_initial: Duration,
95 delay_max: Duration,
96 delay_current: Duration,
97 factor: f64,
98 jitter_ms: u64,
99 immediate_reconnect: bool,
100 immediate_reconnect_original: bool,
101}
102
103impl ExponentialBackoff {
110 pub fn new(
120 delay_initial: Duration,
121 delay_max: Duration,
122 factor: f64,
123 jitter_ms: u64,
124 immediate_first: bool,
125 ) -> anyhow::Result<Self> {
126 check_predicate_true(!delay_initial.is_zero(), "delay_initial must be non-zero")?;
127 check_predicate_true(
128 delay_max >= delay_initial,
129 "delay_max must be >= delay_initial",
130 )?;
131 check_predicate_true(
132 delay_max.as_nanos() <= u128::from(u64::MAX),
133 "delay_max exceeds maximum representable duration (≈584 years)",
134 )?;
135 check_in_range_inclusive_f64(factor, 1.0, 100.0, "factor")?;
136
137 Ok(Self {
138 delay_initial,
139 delay_max,
140 delay_current: delay_initial,
141 factor,
142 jitter_ms,
143 immediate_reconnect: immediate_first,
144 immediate_reconnect_original: immediate_first,
145 })
146 }
147
148 pub fn next_duration(&mut self) -> Duration {
158 if self.immediate_reconnect && self.delay_current == self.delay_initial {
159 self.immediate_reconnect = false;
160 return Duration::ZERO;
161 }
162
163 let jitter = rand::rng().random_range(0..=self.jitter_ms); let base = std::cmp::min(
168 self.delay_current,
169 self.delay_max
170 .saturating_sub(Duration::from_millis(self.jitter_ms)),
171 );
172 let delay_with_jitter = base + Duration::from_millis(jitter);
173
174 let floor = std::cmp::min(self.delay_initial, self.delay_max);
176 let clamped_delay = delay_with_jitter.clamp(floor, self.delay_max);
177
178 let current_nanos = self.delay_current.as_nanos() as u64;
181 let max_nanos = self.delay_max.as_nanos() as u64;
182 let next_nanos = (current_nanos as f64 * self.factor) as u64;
183 self.delay_current = Duration::from_nanos(next_nanos.min(max_nanos));
184
185 clamped_delay
186 }
187
188 pub const fn reset(&mut self) {
190 self.delay_current = self.delay_initial;
191 self.immediate_reconnect = self.immediate_reconnect_original;
192 }
193
194 #[must_use]
198 pub const fn current_delay(&self) -> Duration {
199 self.delay_current
200 }
201}
202
203pub(crate) async fn wait_reconnect_delay(
204 duration: Duration,
205 connection_mode: &AtomicU8,
206 state_notify: &tokio::sync::Notify,
207) -> bool {
208 if duration.is_zero() {
209 return true;
210 }
211
212 tokio::select! {
213 biased;
214 () = dst::time::sleep(duration) => true,
215 () = async {
216 loop {
217 let mut notified = pin!(state_notify.notified());
218 notified.as_mut().enable();
219
220 if !ConnectionMode::from_atomic(connection_mode).is_reconnect() {
221 break;
222 }
223 notified.await;
224 }
225 } => false,
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use std::time::Duration;
232
233 use rstest::rstest;
234
235 use super::*;
236
237 #[rstest]
238 fn test_no_jitter_exponential_growth() {
239 let initial = Duration::from_millis(100);
240 let max = Duration::from_millis(1600);
241 let factor = 2.0;
242 let jitter = 0;
243 let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
244
245 let d1 = backoff.next_duration();
247 assert_eq!(d1, Duration::from_millis(100));
248
249 let d2 = backoff.next_duration();
251 assert_eq!(d2, Duration::from_millis(200));
252
253 let d3 = backoff.next_duration();
255 assert_eq!(d3, Duration::from_millis(400));
256
257 let d4 = backoff.next_duration();
259 assert_eq!(d4, Duration::from_millis(800));
260
261 let d5 = backoff.next_duration();
263 assert_eq!(d5, Duration::from_millis(1600));
264
265 let d6 = backoff.next_duration();
267 assert_eq!(d6, Duration::from_millis(1600));
268 }
269
270 #[rstest]
271 fn test_reset() {
272 let initial = Duration::from_millis(100);
273 let max = Duration::from_millis(1600);
274 let factor = 2.0;
275 let jitter = 0;
276 let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
277
278 let _ = backoff.next_duration(); backoff.reset();
281 let d = backoff.next_duration();
282 assert_eq!(d, Duration::from_millis(100));
284 }
285
286 #[rstest]
287 fn test_jitter_within_bounds() {
288 let initial = Duration::from_millis(100);
289 let max = Duration::from_secs(1);
290 let factor = 2.0;
291 let jitter = 50;
292 for _ in 0..10 {
294 let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
295 let base = backoff.delay_current;
297 let delay = backoff.next_duration();
298 let min_expected = base;
300 let max_expected = base + Duration::from_millis(jitter);
301 assert!(
302 delay >= min_expected,
303 "Delay {delay:?} is less than expected minimum {min_expected:?}"
304 );
305 assert!(
306 delay <= max_expected,
307 "Delay {delay:?} exceeds expected maximum {max_expected:?}"
308 );
309 }
310 }
311
312 #[rstest]
313 fn test_factor_less_than_two() {
314 let initial = Duration::from_millis(100);
315 let max = Duration::from_millis(200);
316 let factor = 1.5;
317 let jitter = 0;
318 let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
319
320 let d1 = backoff.next_duration();
322 assert_eq!(d1, Duration::from_millis(100));
323
324 let d2 = backoff.next_duration();
326 assert_eq!(d2, Duration::from_millis(150));
327
328 let d3 = backoff.next_duration();
330 assert_eq!(d3, Duration::from_millis(200));
331
332 let d4 = backoff.next_duration();
334 assert_eq!(d4, Duration::from_millis(200));
335 }
336
337 #[rstest]
338 fn test_max_delay_is_respected() {
339 let initial = Duration::from_millis(500);
340 let max = Duration::from_secs(1);
341 let factor = 3.0;
342 let jitter = 0;
343 let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
344
345 let d1 = backoff.next_duration();
347 assert_eq!(d1, Duration::from_millis(500));
348
349 let d2 = backoff.next_duration();
351 assert_eq!(d2, Duration::from_secs(1));
352
353 let d3 = backoff.next_duration();
355 assert_eq!(d3, Duration::from_secs(1));
356 }
357
358 #[rstest]
359 fn test_current_delay_getter() {
360 let initial = Duration::from_millis(100);
361 let max = Duration::from_millis(1600);
362 let factor = 2.0;
363 let jitter = 0;
364 let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
365
366 assert_eq!(backoff.current_delay(), initial);
367
368 let _ = backoff.next_duration();
369 assert_eq!(backoff.current_delay(), Duration::from_millis(200));
370
371 let _ = backoff.next_duration();
372 assert_eq!(backoff.current_delay(), Duration::from_millis(400));
373
374 backoff.reset();
375 assert_eq!(backoff.current_delay(), initial);
376 }
377
378 #[rstest]
379 fn test_validation_zero_initial_delay() {
380 let result = ExponentialBackoff::new(Duration::ZERO, Duration::from_secs(1), 2.0, 0, false);
381 assert!(result.is_err());
382 assert!(
383 result
384 .unwrap_err()
385 .to_string()
386 .contains("delay_initial must be non-zero")
387 );
388 }
389
390 #[rstest]
391 fn test_validation_max_less_than_initial() {
392 let result = ExponentialBackoff::new(
393 Duration::from_secs(1),
394 Duration::from_millis(500),
395 2.0,
396 0,
397 false,
398 );
399 assert!(result.is_err());
400 assert!(
401 result
402 .unwrap_err()
403 .to_string()
404 .contains("delay_max must be >= delay_initial")
405 );
406 }
407
408 #[rstest]
409 fn test_validation_factor_too_small() {
410 let result = ExponentialBackoff::new(
411 Duration::from_millis(100),
412 Duration::from_secs(1),
413 0.5,
414 0,
415 false,
416 );
417 assert!(result.is_err());
418 assert!(result.unwrap_err().to_string().contains("factor"));
419 }
420
421 #[rstest]
422 fn test_validation_factor_too_large() {
423 let result = ExponentialBackoff::new(
424 Duration::from_millis(100),
425 Duration::from_secs(1),
426 150.0,
427 0,
428 false,
429 );
430 assert!(result.is_err());
431 assert!(result.unwrap_err().to_string().contains("factor"));
432 }
433
434 #[rstest]
435 fn test_validation_delay_max_exceeds_u64_max_nanos() {
436 let max_valid = Duration::from_nanos(u64::MAX);
439 let too_large = max_valid + Duration::from_nanos(1);
440
441 let result = ExponentialBackoff::new(Duration::from_millis(100), too_large, 2.0, 0, false);
442 assert!(result.is_err());
443 assert!(
444 result
445 .unwrap_err()
446 .to_string()
447 .contains("delay_max exceeds maximum representable duration")
448 );
449 }
450
451 #[rstest]
452 fn test_immediate_first() {
453 let initial = Duration::from_millis(100);
454 let max = Duration::from_millis(1600);
455 let factor = 2.0;
456 let jitter = 0;
457 let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, true).unwrap();
458
459 let d1 = backoff.next_duration();
461 assert_eq!(
462 d1,
463 Duration::ZERO,
464 "Expected immediate reconnect (zero delay) on first call"
465 );
466
467 let d2 = backoff.next_duration();
469 assert_eq!(
470 d2, initial,
471 "Expected the delay to be the initial delay after immediate reconnect"
472 );
473
474 let d3 = backoff.next_duration();
476 let expected = initial * 2; assert_eq!(
478 d3, expected,
479 "Expected exponential growth from the initial delay"
480 );
481 }
482
483 #[rstest]
484 fn test_reset_restores_immediate_first() {
485 let initial = Duration::from_millis(100);
486 let max = Duration::from_millis(1600);
487 let factor = 2.0;
488 let jitter = 0;
489 let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, true).unwrap();
490
491 let d1 = backoff.next_duration();
493 assert_eq!(d1, Duration::ZERO);
494
495 let d2 = backoff.next_duration();
497 assert_eq!(d2, initial);
498
499 backoff.reset();
501 let d3 = backoff.next_duration();
502 assert_eq!(
503 d3,
504 Duration::ZERO,
505 "Reset should restore immediate_first behavior"
506 );
507 }
508
509 #[rstest]
510 fn test_jitter_never_exceeds_max_delay() {
511 let initial = Duration::from_millis(100);
512 let max = Duration::from_secs(1);
513 let factor = 2.0;
514 let jitter = 500;
515
516 let mut backoff = ExponentialBackoff::new(initial, max, factor, jitter, false).unwrap();
517
518 while backoff.current_delay() < max {
520 backoff.next_duration();
521 }
522
523 for _ in 0..100 {
525 let delay = backoff.next_duration();
526 assert!(
527 delay <= max,
528 "Delay with jitter {delay:?} exceeded max {max:?}"
529 );
530 }
531 }
532
533 #[rstest]
534 fn test_jitter_spreads_delays_at_cap() {
535 let initial = Duration::from_millis(100);
539 let max = Duration::from_secs(1);
540 let mut backoff = ExponentialBackoff::new(initial, max, 2.0, 500, false).unwrap();
541
542 while backoff.current_delay() < max {
543 backoff.next_duration();
544 }
545
546 let mut distinct = std::collections::HashSet::new();
547 for _ in 0..100 {
548 distinct.insert(backoff.next_duration());
549 }
550
551 assert!(
552 distinct.len() >= 2,
553 "Jitter must keep spreading delays once the backoff saturates at the cap"
554 );
555 }
556
557 #[rstest]
558 fn test_jitter_wider_than_max_never_returns_zero_delay() {
559 let max = Duration::from_millis(50);
562 let mut backoff =
563 ExponentialBackoff::new(Duration::from_millis(10), max, 2.0, 100, false).unwrap();
564
565 for _ in 0..200 {
566 let delay = backoff.next_duration();
567 assert!(
568 !delay.is_zero(),
569 "Non-immediate backoff delay must be positive"
570 );
571 assert!(delay <= max, "Delay {delay:?} exceeded max {max:?}");
572 }
573 }
574
575 #[cfg(not(all(feature = "simulation", madsim)))]
579 #[tokio::test(flavor = "current_thread", start_paused = true)]
580 async fn test_throttle_passes_delay_through_below_attempt_threshold() {
581 let mut throttle = ReconnectThrottle::default();
582
583 for _ in 0..RECONNECT_MIN_DELAY_ATTEMPTS {
584 assert_eq!(
585 throttle.gated_delay(Duration::ZERO),
586 Duration::ZERO,
587 "Cold window must not floor an immediate reconnect"
588 );
589 throttle.record_attempt();
590 }
591 }
592
593 #[cfg(not(all(feature = "simulation", madsim)))]
594 #[tokio::test(flavor = "current_thread", start_paused = true)]
595 async fn test_throttle_floors_delay_once_threshold_trips() {
596 let mut throttle = ReconnectThrottle::default();
597
598 for _ in 0..RECONNECT_MIN_DELAY_ATTEMPTS {
599 throttle.record_attempt();
600 }
601
602 assert_eq!(
603 throttle.gated_delay(Duration::ZERO),
604 RECONNECT_MIN_DELAY,
605 "Hot window must floor an immediate reconnect"
606 );
607 assert_eq!(
608 throttle.gated_delay(Duration::from_millis(25)),
609 RECONNECT_MIN_DELAY,
610 "Hot window must raise a sub-floor backoff delay"
611 );
612 assert_eq!(
613 throttle.gated_delay(Duration::from_secs(5)),
614 Duration::from_secs(5),
615 "Hot window must not lower a backoff delay above the floor"
616 );
617 }
618
619 #[cfg(not(all(feature = "simulation", madsim)))]
620 #[tokio::test(flavor = "current_thread", start_paused = true)]
621 async fn test_throttle_lifts_floor_after_window_expires() {
622 let mut throttle = ReconnectThrottle::default();
623
624 for _ in 0..RECONNECT_MIN_DELAY_ATTEMPTS {
625 throttle.record_attempt();
626 }
627
628 assert_eq!(throttle.gated_delay(Duration::ZERO), RECONNECT_MIN_DELAY);
629
630 dst::time::sleep(RECONNECT_MIN_DELAY_WINDOW + Duration::from_secs(1)).await;
631
632 assert_eq!(
633 throttle.gated_delay(Duration::ZERO),
634 Duration::ZERO,
635 "Floor must lift once the rolling window drains"
636 );
637 }
638
639 #[cfg(not(all(feature = "simulation", madsim)))]
640 #[tokio::test(flavor = "current_thread", start_paused = true)]
641 async fn test_throttle_window_is_purely_time_based() {
642 let mut throttle = ReconnectThrottle::default();
643
644 for _ in 0..RECONNECT_MIN_DELAY_ATTEMPTS {
645 throttle.record_attempt();
646 }
647
648 dst::time::sleep(Duration::from_mins(1)).await;
651 assert_eq!(
652 throttle.gated_delay(Duration::ZERO),
653 RECONNECT_MIN_DELAY,
654 "Stable uptime inside the window must not lift the floor"
655 );
656
657 dst::time::sleep(RECONNECT_MIN_DELAY_WINDOW).await;
658 assert_eq!(
659 throttle.gated_delay(Duration::ZERO),
660 Duration::ZERO,
661 "Floor must lift once attempts drain from the window"
662 );
663 }
664
665 #[cfg(not(all(feature = "simulation", madsim)))]
666 mod throttle_proptests {
667 use proptest::prelude::*;
668 use rstest::rstest;
669
670 use super::*;
671
672 fn build_paused_runtime() -> tokio::runtime::Runtime {
673 tokio::runtime::Builder::new_current_thread()
674 .enable_time()
675 .start_paused(true)
676 .build()
677 .unwrap()
678 }
679
680 #[derive(Clone, Debug)]
681 enum ThrottleOp {
682 Attempt,
683 AdvanceMs(u64),
684 Gate(u64),
685 }
686
687 fn throttle_op_strategy() -> impl Strategy<Value = ThrottleOp> {
688 prop_oneof![
689 3 => Just(ThrottleOp::Attempt),
690 2 => (0u64..=180_000).prop_map(ThrottleOp::AdvanceMs),
691 3 => (0u64..=10_000).prop_map(ThrottleOp::Gate),
692 ]
693 }
694
695 proptest! {
696 #![proptest_config(ProptestConfig {
698 failure_persistence: Some(Box::new(
699 proptest::test_runner::FileFailurePersistence::Direct(
700 concat!(env!("CARGO_MANIFEST_DIR"), "/proptest-regressions/backoff.txt")
701 )
702 )),
703 ..ProptestConfig::default()
704 })]
705
706 #[rstest]
707 fn test_throttle_threshold_boundary(
708 attempt_count in 0usize..=8,
709 input_ms in 0u64..=5_000,
710 ) {
711 let runtime = build_paused_runtime();
712 runtime.block_on(async move {
713 let mut throttle = ReconnectThrottle::default();
714
715 for _ in 0..attempt_count {
716 throttle.record_attempt();
717 }
718
719 let input = Duration::from_millis(input_ms);
720 let expected = if attempt_count >= RECONNECT_MIN_DELAY_ATTEMPTS {
721 input.max(RECONNECT_MIN_DELAY)
722 } else {
723 input
724 };
725 assert_eq!(
726 throttle.gated_delay(input),
727 expected,
728 "Threshold mismatch at {attempt_count} attempts in window"
729 );
730 });
731 }
732
733 #[rstest]
734 fn test_throttle_matches_rolling_window_model(
735 ops in prop::collection::vec(throttle_op_strategy(), 1..=500),
736 ) {
737 let runtime = build_paused_runtime();
738 runtime.block_on(async move {
739 let mut throttle = ReconnectThrottle::default();
740 let mut attempt_times_ms: Vec<u64> = Vec::new();
741 let mut now_ms = 0u64;
742 let window_ms = RECONNECT_MIN_DELAY_WINDOW.as_millis() as u64;
743
744 for op in ops {
745 match op {
746 ThrottleOp::Attempt => {
747 throttle.record_attempt();
748 attempt_times_ms.push(now_ms);
749 }
750 ThrottleOp::AdvanceMs(ms) => {
751 dst::time::sleep(Duration::from_millis(ms)).await;
752 now_ms += ms;
753 }
754 ThrottleOp::Gate(input_ms) => {
755 let input = Duration::from_millis(input_ms);
756 let kept = attempt_times_ms
757 .iter()
758 .filter(|t| now_ms - *t <= window_ms)
759 .count();
760 let expected = if kept >= RECONNECT_MIN_DELAY_ATTEMPTS {
761 input.max(RECONNECT_MIN_DELAY)
762 } else {
763 input
764 };
765 let gated = throttle.gated_delay(input);
766 assert_eq!(
767 gated, expected,
768 "Model mismatch at {now_ms}ms with {kept} attempts in window"
769 );
770 assert!(
771 gated >= input,
772 "Floor must never lower the backoff delay"
773 );
774 }
775 }
776 }
777 });
778 }
779 }
780 }
781}