use std::collections::VecDeque;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::proto::models::WebsocketReconnectStrategy;
pub const DEFAULT_MAX_JOIN_RETRIES: u32 = 3;
pub const REJOIN_RATE_LIMIT: usize = 10;
pub const REJOIN_RATE_WINDOW: Duration = Duration::from_secs(120);
pub const MAX_ICE_FAILURES_WITHOUT_CONNECT: u32 = 2;
pub const MAX_CONSECUTIVE_NEGOTIATION_FAILURES: u32 = 3;
pub const REASON_REJOIN_LIMIT: &str = "rejoin_attempt_limit_exceeded";
pub const REASON_ICE_UNSUPPORTED: &str = "webrtc_unsupported_network";
pub const REASON_NEGOTIATION_FAILURES: &str = "repeated_negotiation_failures";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReconnectStrategy {
Fast,
Rejoin,
Migrate,
Disconnect,
}
impl ReconnectStrategy {
pub fn from_proto(value: i32) -> Option<Self> {
match value {
v if v == WebsocketReconnectStrategy::Fast as i32 => Some(Self::Fast),
v if v == WebsocketReconnectStrategy::Rejoin as i32 => Some(Self::Rejoin),
v if v == WebsocketReconnectStrategy::Migrate as i32 => Some(Self::Migrate),
v if v == WebsocketReconnectStrategy::Disconnect as i32 => Some(Self::Disconnect),
_ => None,
}
}
pub fn as_proto(self) -> i32 {
match self {
Self::Fast => WebsocketReconnectStrategy::Fast as i32,
Self::Rejoin => WebsocketReconnectStrategy::Rejoin as i32,
Self::Migrate => WebsocketReconnectStrategy::Migrate as i32,
Self::Disconnect => WebsocketReconnectStrategy::Disconnect as i32,
}
}
pub fn is_rate_limited(self) -> bool {
matches!(self, Self::Rejoin | Self::Migrate)
}
}
pub fn retry_interval(n: u32) -> Duration {
let n = i64::from(n);
let max = (500 + n * 2000).min(5000);
let min = ((n - 1) * 2000).clamp(250, 5000);
let span = (max - min).max(0) as u64;
let delay = min as u64 + full_jitter(span);
Duration::from_millis(delay)
}
fn full_jitter(span: u64) -> u64 {
if span == 0 {
return 0;
}
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| u64::from(d.subsec_nanos()))
.unwrap_or(0);
nanos % (span + 1)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JoinAttemptOutcome {
Abort,
Exhausted,
Retry {
delay: Duration,
switch_sfu: bool,
},
}
pub fn evaluate_join_failure(
unrecoverable: bool,
is_join_error_code: bool,
edge_failures: u32,
attempt: u32,
max_retries: u32,
) -> JoinAttemptOutcome {
if unrecoverable {
return JoinAttemptOutcome::Abort;
}
let max_retries = max_retries.max(1);
if attempt + 1 >= max_retries {
return JoinAttemptOutcome::Exhausted;
}
JoinAttemptOutcome::Retry {
delay: retry_interval(attempt + 1),
switch_sfu: is_join_error_code || edge_failures >= 2,
}
}
#[derive(Debug, Clone)]
pub struct SlidingWindowRateLimiter {
max: usize,
window_ms: u64,
events: VecDeque<u64>,
}
impl SlidingWindowRateLimiter {
pub fn new(max: usize, window: Duration) -> Self {
Self {
max: max.max(1),
window_ms: window.as_millis() as u64,
events: VecDeque::new(),
}
}
pub fn rejoin_default() -> Self {
Self::new(REJOIN_RATE_LIMIT, REJOIN_RATE_WINDOW)
}
pub fn try_register(&mut self, now_ms: u64) -> bool {
while let Some(&front) = self.events.front() {
if now_ms.saturating_sub(front) >= self.window_ms {
self.events.pop_front();
} else {
break;
}
}
if self.events.len() >= self.max {
return false;
}
self.events.push_back(now_ms);
true
}
}
#[derive(Debug, Clone)]
pub struct FailureCaps {
ice_failures_without_connect: u32,
consecutive_negotiation_failures: u32,
max_ice_failures: u32,
max_consecutive_negotiation: u32,
}
impl Default for FailureCaps {
fn default() -> Self {
Self {
ice_failures_without_connect: 0,
consecutive_negotiation_failures: 0,
max_ice_failures: MAX_ICE_FAILURES_WITHOUT_CONNECT,
max_consecutive_negotiation: MAX_CONSECUTIVE_NEGOTIATION_FAILURES,
}
}
}
impl FailureCaps {
pub fn record_ice_never_connected(&mut self) -> bool {
self.ice_failures_without_connect += 1;
self.ice_failures_without_connect >= self.max_ice_failures
}
pub fn reset_ice(&mut self) {
self.ice_failures_without_connect = 0;
}
pub fn record_negotiation_failure(&mut self) -> bool {
self.consecutive_negotiation_failures += 1;
self.consecutive_negotiation_failures >= self.max_consecutive_negotiation
}
pub fn reset_negotiation(&mut self) {
self.consecutive_negotiation_failures = 0;
}
}
pub fn escalate_strategy(
elapsed: Duration,
fast_reconnect_deadline: Duration,
was_migrating: bool,
attempt: u32,
publisher_healthy: bool,
subscriber_healthy: bool,
) -> ReconnectStrategy {
let should_rejoin = elapsed > fast_reconnect_deadline
|| was_migrating
|| attempt >= 3
|| !publisher_healthy
|| !subscriber_healthy;
if should_rejoin {
ReconnectStrategy::Rejoin
} else {
ReconnectStrategy::Fast
}
}
pub fn strategy_after_signal_close(
publisher_healthy: bool,
subscriber_healthy: bool,
) -> ReconnectStrategy {
if publisher_healthy && subscriber_healthy {
ReconnectStrategy::Fast
} else {
ReconnectStrategy::Rejoin
}
}
pub(crate) fn disconnection_timed_out(elapsed: Duration, timeout: Duration) -> bool {
!timeout.is_zero() && elapsed > timeout
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retry_interval_matches_js_bounds() {
for _ in 0..100 {
let d = retry_interval(0).as_millis() as i64;
assert!((250..=500).contains(&d), "n=0 out of range: {d}");
}
for _ in 0..100 {
let d = retry_interval(1).as_millis() as i64;
assert!((250..=2500).contains(&d), "n=1 out of range: {d}");
}
for _ in 0..100 {
let d = retry_interval(3).as_millis() as i64;
assert!((4000..=5000).contains(&d), "n=3 out of range: {d}");
}
assert_eq!(retry_interval(100).as_millis(), 5000);
}
#[test]
fn join_abort_is_immediate_and_no_sleep() {
let outcome = evaluate_join_failure(true, false, 1, 0, 3);
assert_eq!(outcome, JoinAttemptOutcome::Abort);
}
#[test]
fn join_exhausts_after_max_retries() {
assert_eq!(
evaluate_join_failure(false, false, 1, 2, 3),
JoinAttemptOutcome::Exhausted
);
assert_eq!(
evaluate_join_failure(false, false, 1, 0, 0),
JoinAttemptOutcome::Exhausted
);
}
#[test]
fn join_switches_sfu_on_join_error_code_or_two_edge_failures() {
match evaluate_join_failure(false, true, 1, 0, 3) {
JoinAttemptOutcome::Retry { switch_sfu, .. } => assert!(switch_sfu),
other => panic!("expected retry, got {other:?}"),
}
match evaluate_join_failure(false, false, 2, 0, 3) {
JoinAttemptOutcome::Retry { switch_sfu, .. } => assert!(switch_sfu),
other => panic!("expected retry, got {other:?}"),
}
match evaluate_join_failure(false, false, 1, 0, 3) {
JoinAttemptOutcome::Retry { switch_sfu, .. } => assert!(!switch_sfu),
other => panic!("expected retry, got {other:?}"),
}
}
#[test]
fn rate_limiter_allows_max_then_blocks_within_window() {
let mut rl = SlidingWindowRateLimiter::new(10, Duration::from_secs(120));
for i in 0..10 {
assert!(rl.try_register(i * 100), "registration {i} should pass");
}
assert!(!rl.try_register(1000));
assert!(rl.try_register(120_001));
}
#[test]
fn ice_cap_trips_on_second_failure() {
let mut caps = FailureCaps::default();
assert!(!caps.record_ice_never_connected());
assert!(caps.record_ice_never_connected());
caps.reset_ice();
assert!(!caps.record_ice_never_connected());
}
#[test]
fn negotiation_cap_trips_on_third_failure() {
let mut caps = FailureCaps::default();
assert!(!caps.record_negotiation_failure());
assert!(!caps.record_negotiation_failure());
assert!(caps.record_negotiation_failure());
caps.reset_negotiation();
assert!(!caps.record_negotiation_failure());
}
#[test]
fn escalation_prefers_rejoin_on_deadline_or_unhealthy() {
assert_eq!(
escalate_strategy(
Duration::from_secs(1),
Duration::from_secs(5),
false,
0,
true,
true
),
ReconnectStrategy::Fast
);
assert_eq!(
escalate_strategy(
Duration::from_secs(6),
Duration::from_secs(5),
false,
0,
true,
true
),
ReconnectStrategy::Rejoin
);
assert_eq!(
escalate_strategy(
Duration::from_secs(1),
Duration::from_secs(5),
false,
0,
false,
true
),
ReconnectStrategy::Rejoin
);
}
#[test]
fn signal_close_strategy_depends_on_pc_health() {
assert_eq!(
strategy_after_signal_close(true, true),
ReconnectStrategy::Fast
);
assert_eq!(
strategy_after_signal_close(true, false),
ReconnectStrategy::Rejoin
);
}
#[test]
fn only_rejoin_and_migrate_are_rate_limited() {
assert!(ReconnectStrategy::Rejoin.is_rate_limited());
assert!(ReconnectStrategy::Migrate.is_rate_limited());
assert!(!ReconnectStrategy::Fast.is_rate_limited());
assert!(!ReconnectStrategy::Disconnect.is_rate_limited());
}
#[test]
fn zero_disconnection_timeout_is_unlimited() {
assert!(!disconnection_timed_out(
Duration::from_secs(86_400),
Duration::ZERO
));
assert!(!disconnection_timed_out(
Duration::from_secs(5),
Duration::from_secs(5)
));
assert!(disconnection_timed_out(
Duration::from_secs(6),
Duration::from_secs(5)
));
}
}