asterdex-sdk 0.1.5

AsterDex Futures SDK v3 — Rust async client for REST and WebSocket APIs
Documentation
// US-010: Reconnect engine with exponential backoff (ADR-006)

use std::time::Duration;

/// Default initial backoff delay: 1 second.
pub(crate) const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);

/// Default maximum backoff delay: 30 seconds.
pub(crate) const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);

/// Reconnect backoff state machine — pure state, no I/O.
///
/// Tracks attempt count and computes the next exponential-backoff delay.
/// Sequence: 1s -> 2s -> 4s -> 8s -> 16s -> 30s (capped at `max_backoff`).
pub(crate) struct ReconnectState {
    initial_backoff: Duration,
    max_backoff: Duration,
    max_attempts: Option<u32>,
    pub(crate) current_attempt: u32,
    current_backoff: Duration,
}

impl ReconnectState {
    /// Create a new reconnect state machine.
    ///
    /// - `initial_backoff`: delay before the first reconnect attempt.
    /// - `max_backoff`: upper bound on the backoff delay.
    /// - `max_attempts`: optional cap on total reconnect attempts (`None` = unlimited).
    pub fn new(
        initial_backoff: Duration,
        max_backoff: Duration,
        max_attempts: Option<u32>,
    ) -> Self {
        Self {
            initial_backoff,
            max_backoff,
            max_attempts,
            current_attempt: 0,
            current_backoff: initial_backoff,
        }
    }

    /// Returns the delay for the next reconnect attempt, or `None` if max attempts reached.
    ///
    /// Each call doubles the backoff (capped at `max_backoff`) and increments the attempt counter.
    pub fn next_backoff(&mut self) -> Option<Duration> {
        if let Some(max) = self.max_attempts {
            if self.current_attempt >= max {
                return None;
            }
        }
        let backoff = self.current_backoff;
        self.current_attempt += 1;
        // Double for next call, capped at max_backoff
        self.current_backoff = (self.current_backoff * 2).min(self.max_backoff);
        Some(backoff)
    }

    /// Reset state after a successful connection — restores initial backoff and zeroes attempt count.
    pub fn reset(&mut self) {
        self.current_attempt = 0;
        self.current_backoff = self.initial_backoff;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Duration;

    // US-010: Verify backoff sequence matches AC: 1s -> 2s -> 4s -> 8s -> 16s -> 30s -> 30s
    #[test]
    fn backoff_sequence_is_correct() {
        let mut state = ReconnectState::new(
            Duration::from_secs(1),
            Duration::from_secs(30),
            None, // unlimited attempts
        );
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(1)));
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(2)));
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(4)));
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(8)));
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(16)));
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(30))); // capped
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(30))); // stays capped
    }

    // US-010: After N attempts, the (N+1)th call returns None
    #[test]
    fn max_attempts_returns_none() {
        let mut state = ReconnectState::new(
            Duration::from_secs(1),
            Duration::from_secs(30),
            Some(3),
        );
        assert!(state.next_backoff().is_some()); // attempt 1
        assert!(state.next_backoff().is_some()); // attempt 2
        assert!(state.next_backoff().is_some()); // attempt 3
        assert!(state.next_backoff().is_none()); // 4th call -> None
    }

    // US-010: After reset, next_backoff returns initial_backoff again
    #[test]
    fn reset_restores_initial_state() {
        let mut state = ReconnectState::new(
            Duration::from_secs(1),
            Duration::from_secs(30),
            Some(5),
        );
        // Consume a few attempts
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(1)));
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(2)));
        assert_eq!(state.current_attempt, 2);

        // Reset
        state.reset();
        assert_eq!(state.current_attempt, 0);
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(1)));
        assert_eq!(state.next_backoff(), Some(Duration::from_secs(2)));
    }
}