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 diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index 5c3b2a077..4ef28bbf0 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); + }); + }); +}