Skip to content
Merged
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 .changes/region-pinning-403-failover
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Fail over to other Cloud regions when the initial connection is rejected with 403"
4 changes: 1 addition & 3 deletions lib/src/core/room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import '../support/disposable.dart';
import '../support/http_client.dart';
import '../support/platform.dart';
import '../support/region_url_provider.dart';
import '../support/websocket.dart' show WebSocketException;
import '../track/audio_management.dart';
import '../track/local/audio.dart';
import '../track/local/video.dart';
Expand Down Expand Up @@ -367,8 +366,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
didConnect = true;
} catch (e) {
logger.warning('could not connect to $url $e');
if (_regionUrlProvider != null &&
(e is WebSocketException || (e is ConnectException && e.reason != ConnectionErrorReason.NotAllowed))) {
if (_regionUrlProvider != null && canFailOverToAnotherRegion(e)) {
String? nextUrl;
try {
nextUrl = await _regionUrlProvider!.getNextBestRegionUrl();
Expand Down
28 changes: 28 additions & 0 deletions lib/src/support/region_url_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import '../logger.dart';
import '../options.dart';
import '../proto/livekit_rtc.pb.dart' as lk_models;
import 'http_client.dart';
import 'websocket.dart' show WebSocketException;

class RegionUrlProvider {
Uri serverUrl;
Expand Down Expand Up @@ -128,6 +129,33 @@ bool isCloudUrl(Uri uri) {
return uri.host.contains('.livekit.cloud') || uri.host.contains('.livekit.run');
}

/// Whether a failed connection attempt may be retried against a different LiveKit Cloud region.
///
/// LiveKit Cloud signals project-level region pinning by returning 403 on the RTC paths when the
/// project is not allowed in the region the client geo-routed to. `/settings/regions` is
/// deliberately left reachable so the client can discover its allowed regions and connect there,
/// so a 403 must not be treated as terminal.
///
/// A 401 stays terminal: no other region will accept a token this one rejected.
///
/// We key on the status rather than the server's error message because that message is an
/// unversioned human-readable string; matching it would let a copy edit break already-shipped
/// clients. If a 403 really was a permissions failure rather than region pinning, every region
/// attempt fails the same way and the original error still surfaces — at the cost of one extra
/// region lookup.
bool canFailOverToAnotherRegion(Object error) {
if (error is WebSocketException) {
return true;
}
if (error is ConnectException) {
if (error.reason == ConnectionErrorReason.NotAllowed) {
return error.statusCode == 403;
}
return true;
}
return false;
}

String toHttpUrl(String url) {
if (url.startsWith('ws')) {
return url.replaceFirst('ws', 'http');
Expand Down
70 changes: 70 additions & 0 deletions test/support/region_failover_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:flutter_test/flutter_test.dart';

import 'package:livekit_client/src/exceptions.dart';
import 'package:livekit_client/src/support/region_url_provider.dart';
import 'package:livekit_client/src/support/websocket.dart' show WebSocketException;

void main() {
group('canFailOverToAnotherRegion', () {
test('allows failover on 403, which LiveKit Cloud uses to signal region pinning', () {
// The RTC paths 403 when the project is not allowed in the region the client
// geo-routed to. /settings/regions stays reachable so the client can discover
// where it is allowed to connect.
expect(
canFailOverToAnotherRegion(
ConnectException(
'project not allowed in this region.',
reason: ConnectionErrorReason.NotAllowed,
statusCode: 403,
),
),
isTrue,
);
});

test('does not allow failover on 401 — no other region will accept the same token', () {
expect(
canFailOverToAnotherRegion(
ConnectException(
'unauthorized',
reason: ConnectionErrorReason.NotAllowed,
statusCode: 401,
),
),
isFalse,
);
});

test('allows failover on a websocket error', () {
expect(canFailOverToAnotherRegion(const WebSocketException('failed')), isTrue);
});

test('allows failover on non-NotAllowed connect errors', () {
for (final reason in [ConnectionErrorReason.InternalError, ConnectionErrorReason.Timeout]) {
expect(
canFailOverToAnotherRegion(ConnectException('failed', reason: reason)),
isTrue,
reason: 'expected failover for $reason',
);
}
});

test('does not allow failover on unrelated errors', () {
expect(canFailOverToAnotherRegion(StateError('boom')), isFalse);
});
});
}
Loading