///|
/// Voice gateway lifecycle state.
#doc(hidden)
pub(all) enum VoiceGatewayState {
Disconnected(reconnect_attempts~ : Int32)
Connecting
Identifying
SelectingProtocol
Active
Resuming
FatallyClosed(code~ : Int32)
} derive(Debug, Eq)
///|
/// Action after a voice WebSocket close: resume the session, rejoin from
/// scratch, or give up because reconnecting cannot succeed.
#doc(hidden)
pub(all) enum VoiceReconnectPolicy {
Resume
Rejoin
Fatal
} derive(Debug, Eq)
///|
/// Classify a voice WebSocket close code. A resumable transport failure still
/// requires a fresh join when no established voice session exists.
#doc(hidden)
pub fn voice_on_close(
code : Int32?,
has_session : Bool,
) -> VoiceReconnectPolicy {
let policy = match code {
// Unknown opcode: the client and server cannot speak the same protocol.
Some(4001)
// Failed payload decode: reconnecting would resend the same invalid data.
| Some(4002)
// Not authenticated: identify/resume credentials are invalid.
| Some(4003)
// Authentication failed: the voice token is invalid.
| Some(4004)
// Already authenticated: the client state machine is invalid.
| Some(4005)
// Server not found: the requested voice server is invalid.
| Some(4011)
// Unknown protocol: the selected transport protocol is unsupported.
| Some(4012)
// Disconnected: channel access or voice state is no longer valid.
| Some(4014)
// Unknown encryption mode: protocol selection cannot succeed unchanged.
| Some(4016)
// Bad request: the client sent a structurally invalid request.
| Some(4020)
// Disconnected rate limited: retrying this session cannot succeed safely.
| Some(4021)
// Call terminated: the server has ended this voice session.
| Some(4022) => Fatal
// Session no longer valid: the main gateway must issue a fresh voice join.
Some(4006)
// Session timed out: buffered resume state has expired.
| Some(4009)
// A normal/going-away close invalidates the current voice session.
| Some(1000)
| Some(1001) => Rejoin
// Voice server crashed: Discord explicitly permits buffered resume.
Some(4015) => Resume
// Abnormal and unclassified failures are treated as transient.
_ => Resume
}
if policy is Resume && !has_session {
Rejoin
} else {
policy
}
}
///|
/// Exclusive upper bound of the full-jitter delay before reconnect attempt
/// `attempts`: one second doubled per consecutive failure, capped at 30 s.
fn reconnect_delay_limit(attempts : Int32) -> Int32 {
let exponent = if attempts > 5 { 5 } else { attempts }
let max_delay = 1000 * (1 << exponent)
if max_delay > 30000 {
30000
} else {
max_delay
}
}
///|
/// What the gateway loop does after a voice connection ends.
priv struct CloseOutcome {
state : VoiceGatewayState
event : VoiceGatewayEvent
attempts : Int32
stop : Bool
}
///|
/// Decide the next state, the event to surface, the consecutive-failure count,
/// and whether the loop stops after a connection ends with close `code`. A
/// local shutdown (`closing`) always stops with a non-resuming disconnect.
fn close_outcome(
code : Int32?,
closing~ : Bool,
has_session~ : Bool,
attempts~ : Int32,
) -> CloseOutcome {
if closing {
return {
state: Disconnected(reconnect_attempts=0),
event: Disconnected(code~, resuming=false),
attempts,
stop: true,
}
}
match voice_on_close(code, has_session) {
Fatal => {
// Unreachable default: a missing close code never classifies as fatal.
let code = code.unwrap_or(0)
{
state: FatallyClosed(code~),
event: FatallyClosed(code~),
attempts,
stop: true,
}
}
Rejoin => {
let attempts = attempts + 1
{
state: Disconnected(reconnect_attempts=attempts),
event: NeedsRejoin(code~),
attempts,
stop: true,
}
}
Resume => {
let attempts = attempts + 1
{
state: Disconnected(reconnect_attempts=attempts),
event: Disconnected(code~, resuming=true),
attempts,
stop: false,
}
}
}
}