use std::time::Duration;
use crate::MarketDataError;
pub const DEFAULT_MAX_ATTEMPTS: u32 = 5;
pub const DEFAULT_INITIAL_DELAY_MS: u64 = 1000;
pub const DEFAULT_MAX_DELAY_MS: u64 = 60000;
pub const MIN_INITIAL_DELAY_MS: u64 = 100;
#[derive(Debug, Clone, bon::Builder)]
pub struct ReconnectionConfig {
#[builder(default = true)]
pub enabled: bool,
#[builder(default = DEFAULT_MAX_ATTEMPTS)]
pub max_attempts: u32,
#[builder(default = Duration::from_millis(DEFAULT_INITIAL_DELAY_MS))]
pub initial_delay: Duration,
#[builder(default = Duration::from_millis(DEFAULT_MAX_DELAY_MS))]
pub max_delay: Duration,
}
impl Default for ReconnectionConfig {
fn default() -> Self {
Self::builder().build()
}
}
impl ReconnectionConfig {
pub fn new(
max_attempts: u32,
initial_delay: Duration,
max_delay: Duration,
) -> Result<Self, MarketDataError> {
if max_attempts == 0 {
return Err(MarketDataError::ConfigError(
"max_attempts must be >= 1".to_string(),
));
}
if initial_delay < Duration::from_millis(MIN_INITIAL_DELAY_MS) {
return Err(MarketDataError::ConfigError(format!(
"initial_delay must be >= {}ms (got {}ms)",
MIN_INITIAL_DELAY_MS,
initial_delay.as_millis()
)));
}
if max_delay < initial_delay {
return Err(MarketDataError::ConfigError(format!(
"max_delay ({}ms) must be >= initial_delay ({}ms)",
max_delay.as_millis(),
initial_delay.as_millis()
)));
}
Ok(Self {
enabled: true,
max_attempts,
initial_delay,
max_delay,
})
}
#[must_use]
pub fn disabled() -> Self {
Self {
enabled: false,
..Self::default()
}
}
}
pub struct ReconnectionManager {
config: ReconnectionConfig,
current_attempt: u32,
}
impl ReconnectionManager {
pub fn new(config: ReconnectionConfig) -> Self {
Self {
config,
current_attempt: 0,
}
}
pub fn should_reconnect(&self, close_code: Option<u16>) -> bool {
if !self.config.enabled {
return false;
}
match close_code {
Some(1000) => false, Some(1001) => true, Some(1006) => true, Some(4001) => false, Some(code) if (4000..=4999).contains(&code) => false, _ => true, }
}
pub fn next_delay(&mut self) -> Option<Duration> {
if self.current_attempt >= self.config.max_attempts {
return None;
}
self.current_attempt += 1;
let exponential_millis = self.config.initial_delay.as_millis()
* 2_u128.pow((self.current_attempt - 1).min(10));
let capped_millis = exponential_millis.min(self.config.max_delay.as_millis());
let jitter_percent = (self.current_attempt * 3) % 16; let jitter = (capped_millis * jitter_percent as u128) / 100;
let final_millis = capped_millis.saturating_add(jitter);
Some(Duration::from_millis(final_millis as u64))
}
pub fn reset(&mut self) {
self.current_attempt = 0;
}
pub fn attempts_remaining(&self) -> u32 {
self.config.max_attempts.saturating_sub(self.current_attempt)
}
pub fn current_attempt(&self) -> u32 {
self.current_attempt
}
}
#[cfg(test)]
mod tests {
use super::*;
fn enabled_config() -> ReconnectionConfig {
ReconnectionConfig::new(5, Duration::from_secs(1), Duration::from_secs(60))
.expect("test config is valid")
}
#[test]
fn test_reconnection_config_default() {
let config = ReconnectionConfig::default();
assert!(
config.enabled,
"0.4.0 flipped default to enabled — Rust users get auto-reconnect on the happy path"
);
assert_eq!(config.max_attempts, 5);
assert_eq!(config.initial_delay, Duration::from_secs(1));
assert_eq!(config.max_delay, Duration::from_secs(60));
}
#[test]
fn test_reconnection_config_new_is_enabled() {
let config = enabled_config();
assert!(config.enabled);
}
#[test]
fn test_reconnection_config_disabled_constructor() {
let config = ReconnectionConfig::disabled();
assert!(!config.enabled);
}
#[test]
fn test_disabled_config_never_reconnects() {
let manager = ReconnectionManager::new(ReconnectionConfig::disabled());
assert!(!manager.should_reconnect(Some(1006)));
assert!(!manager.should_reconnect(Some(1001)));
assert!(!manager.should_reconnect(None));
}
#[test]
fn test_reconnection_config_builder() {
let config = ReconnectionConfig::builder()
.max_attempts(10)
.initial_delay(Duration::from_secs(2))
.max_delay(Duration::from_secs(120))
.build();
assert_eq!(config.max_attempts, 10);
assert_eq!(config.initial_delay, Duration::from_secs(2));
assert_eq!(config.max_delay, Duration::from_secs(120));
assert!(
config.enabled,
"builder defaults `enabled` to true, matching Default"
);
}
#[test]
fn test_reconnection_config_builder_defaults_match_default() {
let via_builder = ReconnectionConfig::builder().build();
let via_default = ReconnectionConfig::default();
assert_eq!(via_builder.enabled, via_default.enabled);
assert_eq!(via_builder.max_attempts, via_default.max_attempts);
assert_eq!(via_builder.initial_delay, via_default.initial_delay);
assert_eq!(via_builder.max_delay, via_default.max_delay);
}
#[test]
fn test_should_reconnect_on_1006() {
let manager = ReconnectionManager::new(enabled_config());
assert!(manager.should_reconnect(Some(1006)));
}
#[test]
fn test_should_reconnect_on_1001() {
let manager = ReconnectionManager::new(enabled_config());
assert!(manager.should_reconnect(Some(1001)));
}
#[test]
fn test_should_not_reconnect_on_4001() {
let manager = ReconnectionManager::new(enabled_config());
assert!(!manager.should_reconnect(Some(4001)));
}
#[test]
fn test_should_not_reconnect_on_1000() {
let manager = ReconnectionManager::new(enabled_config());
assert!(!manager.should_reconnect(Some(1000)));
}
#[test]
fn test_should_not_reconnect_on_4xxx() {
let manager = ReconnectionManager::new(enabled_config());
assert!(!manager.should_reconnect(Some(4000)));
assert!(!manager.should_reconnect(Some(4500)));
assert!(!manager.should_reconnect(Some(4999)));
}
#[test]
fn test_should_reconnect_on_unknown() {
let manager = ReconnectionManager::new(enabled_config());
assert!(manager.should_reconnect(Some(1002)));
assert!(manager.should_reconnect(Some(1003)));
assert!(manager.should_reconnect(None));
}
#[test]
fn test_exponential_backoff_delays() {
let config = ReconnectionConfig::default();
let mut manager = ReconnectionManager::new(config);
let delay1 = manager.next_delay();
assert!(delay1.is_some());
assert_eq!(manager.current_attempt(), 1);
let delay2 = manager.next_delay();
assert!(delay2.is_some());
assert_eq!(manager.current_attempt(), 2);
let _ = manager.next_delay();
let _ = manager.next_delay();
let _ = manager.next_delay();
let delay_final = manager.next_delay();
assert!(delay_final.is_none());
}
#[test]
fn test_reset_clears_attempts() {
let config = ReconnectionConfig::default();
let mut manager = ReconnectionManager::new(config);
let _ = manager.next_delay();
let _ = manager.next_delay();
assert_eq!(manager.current_attempt(), 2);
manager.reset();
assert_eq!(manager.current_attempt(), 0);
assert_eq!(manager.attempts_remaining(), 5);
let delay = manager.next_delay();
assert!(delay.is_some());
}
#[test]
fn test_max_attempts_reached() {
let config = ReconnectionConfig::builder().max_attempts(3).build();
let mut manager = ReconnectionManager::new(config);
assert!(manager.next_delay().is_some());
assert!(manager.next_delay().is_some());
assert!(manager.next_delay().is_some());
assert!(manager.next_delay().is_none());
assert_eq!(manager.attempts_remaining(), 0);
}
#[test]
fn test_attempts_remaining() {
let config = ReconnectionConfig::builder().max_attempts(5).build();
let mut manager = ReconnectionManager::new(config);
assert_eq!(manager.attempts_remaining(), 5);
let _ = manager.next_delay();
assert_eq!(manager.attempts_remaining(), 4);
let _ = manager.next_delay();
assert_eq!(manager.attempts_remaining(), 3);
}
#[test]
fn test_reconnection_config_default_uses_constants() {
let config = ReconnectionConfig::default();
assert_eq!(config.max_attempts, DEFAULT_MAX_ATTEMPTS);
assert_eq!(
config.initial_delay,
Duration::from_millis(DEFAULT_INITIAL_DELAY_MS)
);
assert_eq!(
config.max_delay,
Duration::from_millis(DEFAULT_MAX_DELAY_MS)
);
}
#[test]
fn test_new_rejects_zero_max_attempts() {
let result = ReconnectionConfig::new(0, Duration::from_secs(1), Duration::from_secs(60));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("max_attempts"),
"Error should mention field name: {}",
err
);
assert!(
err.contains(">= 1") || err.contains("must be"),
"Error should mention constraint: {}",
err
);
}
#[test]
fn test_new_rejects_too_small_initial_delay() {
let result = ReconnectionConfig::new(5, Duration::from_millis(50), Duration::from_secs(60));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("initial_delay"),
"Error should mention field name: {}",
err
);
assert!(
err.contains("100ms") || err.contains("50ms"),
"Error should show values: {}",
err
);
}
#[test]
fn test_new_rejects_max_delay_less_than_initial() {
let result = ReconnectionConfig::new(5, Duration::from_secs(10), Duration::from_secs(5));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("max_delay"),
"Error should mention field name: {}",
err
);
assert!(
err.contains("initial_delay"),
"Error should mention constraint relationship: {}",
err
);
}
#[test]
fn test_new_accepts_valid_config() {
let result =
ReconnectionConfig::new(3, Duration::from_millis(500), Duration::from_secs(30));
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.max_attempts, 3);
assert_eq!(config.initial_delay, Duration::from_millis(500));
assert_eq!(config.max_delay, Duration::from_secs(30));
}
}