Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions livekit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ anyhow = "1.0.99"
test-log = "0.2.18"
test-case = "3.3"
serial_test = "3.0"
http = "1.1"
90 changes: 90 additions & 0 deletions livekit/src/rtc_engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,14 @@ impl EngineInner {
match try_connect().await {
Ok(res) => return Ok(res),
Err(e) => {
// A validated auth failure (401/403) will not succeed on
// retry with the same token — surface it immediately instead
// of burning the remaining join attempts. Same classification
// as the reconnect loop (see `auth_failure_reason`).
if auth_failure_reason(&e).is_some() {
log::warn!("authentication rejected during connect ({e}); not retrying");
return Err(e);
}
let attempt_i = i + 1;
if i < max_retries {
log::warn!(
Expand Down Expand Up @@ -911,6 +919,14 @@ impl EngineInner {
"server requested disconnect during restart".into(),
));
}
if let Some(reason) = auth_failure_reason(&err) {
log::warn!("authentication rejected during restart ({err}); not retrying");
self.running_handle.write().can_reconnect = false;
self.close(reason).await;
return Err(EngineError::Connection(
"authentication failed during reconnect".into(),
));
}
log::error!("restarting connection failed: {}", err);
}
}
Expand Down Expand Up @@ -939,6 +955,14 @@ impl EngineInner {
"server requested disconnect during resume".into(),
));
}
if let Some(reason) = auth_failure_reason(&err) {
log::warn!("authentication rejected during resume ({err}); not retrying");
self.running_handle.write().can_reconnect = false;
self.close(reason).await;
return Err(EngineError::Connection(
"authentication failed during reconnect".into(),
));
}
log::error!("resuming connection failed: {}", err);
let mut running_handle = self.running_handle.write();
running_handle.full_reconnect = true;
Expand Down Expand Up @@ -1091,6 +1115,28 @@ fn leave_disconnect_reason(err: &EngineError) -> Option<DisconnectReason> {
None
}

/// Inspect a reconnect-attempt error for a genuine authentication/authorization
/// failure (HTTP 401/403). Such a failure will not succeed on retry with the
/// same token, so the reconnect loop should bail out immediately rather than
/// burning every attempt (and hammering the server) with credentials it already
/// knows are rejected.
///
/// We key on `SignalError::Client(401|403)`, which is produced by the server's
/// `rtc/validate` probe (see [`super`]'s `SignalInner::validate`) — an
/// authoritative classification. We deliberately do NOT key on the raw
/// `WsError::Http` upgrade status, because that can be a fabricated 401 masking a
/// transient server error (e.g. a 503 from a saturated node), which IS
/// retryable. A resume that hits a raw 401 simply escalates to a full reconnect,
/// whose connect path runs `validate()` and surfaces the authoritative status.
fn auth_failure_reason(err: &EngineError) -> Option<DisconnectReason> {
if let EngineError::Signal(SignalError::Client(status, _)) = err {
if matches!(status.as_u16(), 401 | 403) {
return Some(DisconnectReason::JoinFailure);
}
}
None
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -1139,6 +1185,50 @@ mod tests {
}
}

#[test]
fn auth_failure_reason_flags_validated_401_and_403() {
// The server's rtc/validate probe surfaces auth failures as Client(4xx).
for status in [401u16, 403] {
let err = EngineError::Signal(SignalError::Client(
http::StatusCode::from_u16(status).unwrap(),
"invalid token".into(),
));
assert_eq!(
auth_failure_reason(&err),
Some(DisconnectReason::JoinFailure),
"Client({status}) must be treated as a non-retryable auth failure"
);
}
}

#[test]
fn auth_failure_reason_ignores_other_client_and_server_errors() {
let not_auth = [
// Other client errors are not auth failures.
EngineError::Signal(SignalError::Client(http::StatusCode::NOT_FOUND, "".into())),
EngineError::Signal(SignalError::Client(
http::StatusCode::TOO_MANY_REQUESTS,
"".into(),
)),
// Server errors (e.g. a saturated node) are retryable.
EngineError::Signal(SignalError::Server(
http::StatusCode::SERVICE_UNAVAILABLE,
"".into(),
)),
// Generic connectivity/internal errors are retryable.
EngineError::Connection("network".into()),
EngineError::Internal("bug".into()),
EngineError::Signal(SignalError::SendError),
EngineError::Signal(SignalError::Timeout("waiting".into())),
];
for err in &not_auth {
assert!(
auth_failure_reason(err).is_none(),
"{err:?} must NOT be treated as an auth failure"
);
}
}

#[test]
fn backoff_nominal_grows_geometrically_then_caps() {
// attempt 1 == base, then x2 each step, until it saturates at the cap.
Expand Down