From cf6eda55e234ddd123124948a3187127de431da5 Mon Sep 17 00:00:00 2001 From: shijing xian Date: Fri, 11 Sep 2026 12:05:13 -0700 Subject: [PATCH 1/2] fix: fail over to other regions when Cloud rejects a connection with 403 LiveKit Cloud enforces project-level region pinning by returning 403 on the RTC paths when a 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. Room.connect excluded every NotAllowed error from region failover, and the validate response maps any status >= 400 to NotAllowed, so a pinned project that geo-routed to a disallowed region gave up before ever fetching the region list. Key on the status rather than the server's message: that message is an unversioned human-readable string, and matching it would let a server-side copy edit break already-shipped clients. 401 stays terminal since no other region will accept the same token. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/core/room.dart | 4 +- lib/src/support/region_url_provider.dart | 28 ++++++++++ test/support/region_failover_test.dart | 70 ++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 test/support/region_failover_test.dart diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index 13a1079f0..78ace73a7 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -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'; @@ -367,8 +366,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { 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(); diff --git a/lib/src/support/region_url_provider.dart b/lib/src/support/region_url_provider.dart index 5bc0732da..5e44d6c33 100644 --- a/lib/src/support/region_url_provider.dart +++ b/lib/src/support/region_url_provider.dart @@ -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; @@ -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'); diff --git a/test/support/region_failover_test.dart b/test/support/region_failover_test.dart new file mode 100644 index 000000000..04f8aac60 --- /dev/null +++ b/test/support/region_failover_test.dart @@ -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); + }); + }); +} From e0617e4b339fc0991c44ba853ea99cdaf3a604da Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:41:52 +0800 Subject: [PATCH 2/2] chore: add changes entry for 403 region failover --- .changes/region-pinning-403-failover | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/region-pinning-403-failover diff --git a/.changes/region-pinning-403-failover b/.changes/region-pinning-403-failover new file mode 100644 index 000000000..e388cef23 --- /dev/null +++ b/.changes/region-pinning-403-failover @@ -0,0 +1 @@ +patch type="fixed" "Fail over to other Cloud regions when the initial connection is rejected with 403" \ No newline at end of file