fix: fail over to other regions when Cloud rejects a connection with 403 - #1120
Conversation
|
|
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. That 403 reaches connectWithCloudRegionFailover as a .validation error, which was short-circuited before any failover could run — and isRetryableForRegionFailover admitted only .network/.timedOut anyway. A pinned project that geo-routed to a disallowed region therefore gave up without ever fetching the region list. Carry the HTTP status on LiveKitError and admit a 403 in isRetryableForRegionFailover, rather than matching 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, as does the 404 that surfaces as .serviceNotFound for the v1 to v0 path fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding a defaulted statusCode parameter to the existing public init is source-compatible for callers, but the Swift API digester reports the changed signature as a removed declaration, failing Check Public API. Keep the public init exactly as it was and add a separate internal initializer that records the status, with the shared userInfo construction hoisted into a helper. statusCode is internal because only the region-failover path reads it; exposing it publicly is a separate decision and shouldn't ride along in a bugfix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Admitting 403 to the failover loop meant a 403 that was an ordinary permission
failure — not Cloud's region pinning — failed identically in every region,
drained `remaining`, and then surfaced as `.regionManager("No more remaining
regions.")` thrown from inside the same `catch`. The server's explanation was
lost, which is the opposite of what the docstring claimed.
`resolveBest` now reports exhaustion as a `nil` result, so the loop can rethrow
the connection error it is already holding. It cannot be a caught `.regionManager`
instead: that type also covers settings fetch, HTTP, empty-data and parse
failures, which still throw and must reach the caller unchanged.
Keep failed regions excluded across a settings refresh, matching
`updateFromServerReportedRegions`. `applyFetchedRegions` reset `remaining` to
every region, so once per-region failure outlasts `cacheInterval` a refresh
returned regions faster than `markFailed` removed them and the loop never
exhausted — `Room.connect` hanging rather than failing. `resetAttempts()` remains
the deliberate way to clear the failed set on a new connect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
376d8da to
3c03b42
Compare
Match rust-sdks on what a failed region lookup means. Its client fallback in `livekit-signaling` logs the lookup failure and returns the original connect error — "that must NOT be fatal: log a warning and fall back to the original connection error rather than masking it with the fetch error" — and tracks `last_err` across region attempts rather than synthesizing an exhaustion error. So exhaustion and a settings fetch/parse failure both surface the connection error here now; the lookup failure is logged at `.warning`. Cap the loop at 10 attempts. Termination previously rested on an invariant about how `remaining` is maintained, while the list is refreshed from the server inside the loop — so any future path that refills it would hang `connect` rather than fail it. rust avoids this structurally by iterating a list fetched once; until this loop is reshaped that way, the cap is what guarantees a return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clear the failed set on a successful connect. Keeping exclusions across a settings refresh fixed the refill hang but removed the only thing that ever un-excluded a region: `resetAttempts()` ran unconditionally only after a successful *reconnect*, and `Room.connect` resets only when every region was already exhausted. So a region that failed transiently before another one connected stayed excluded for the life of the manager, and a later failover skipped it even once it recovered. Resetting on the loop's success path scopes the failed set to one cycle, which is what it was always meant to represent. Let cancellation through the region lookup. The broad catch added with the lookup-failure fallback also caught `CancellationError`, turning a cancelled settings fetch into the preceding connection error; the outer loop treats cancellation specially and this nested catch has to preserve it. Count the cap against resolved regions rather than total connects. The provided URL fails before any region is resolved, so it was consuming one of the ten and a project with exactly ten regions could never reach its last one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| /// iterates a list fetched once (`for region_url in urls.iter()` in `livekit-signaling`), so | ||
| /// nothing can refill it mid-loop. Until this loop is reshaped the same way, the cap is what | ||
| /// guarantees `connect` returns rather than hangs. Comfortably above any real region count. | ||
| private static let maxRegionFailoverAttempts = 10 |
There was a problem hiding this comment.
@pblazej , what do you think about 10 attempt cap ? or we should do less?
There was a problem hiding this comment.
10 is fine — the server already truncates to 6 (len(res.Regions) >= 6 in DefaultRegionMap.GetRegionSettings, a hard constant rather than a per-geography thing), so the cap never fires in practice; I checked four Cloud projects and got exactly 6 each time. I would not go lower: 10 leaves headroom for the prepared-region path (initialRegion is resolved before the loop, so it effectively allows 11) and for that 6 being raised later, whereas anything at or below it turns the safety net into a functional limit.
refreshKeepsFailedRegionsExcluded drives updateFromServerReportedRegions, which already excluded failed regions before this branch. The method this branch changed is applyFetchedRegions, reached only through the settings fetch, so reverting that change leaves the suite green. This test goes through the fetch: MockURLProtocol serves the region settings, one region is marked failed, lastRequested is rewound past cacheInterval so the next resolveBest refetches, and the failed region must not come back at the head of the list. Verified by mutation — restoring `state.remaining = allRegions` fails this test and only this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
isRetryableForRegionFailover checked statusCode == 403 before the switch, so any future error carrying a 403 would opt itself into region failover. Move it into `case .validation`, matching client-sdk-js, where the status only discriminates within the reason it belongs to. Equivalent today: 404 becomes .serviceNotFound before the status is attached, so .validation is the only type that can carry a 403. Make the recording initializer take a non-optional status, so the only way into it is with a real one. Drop the status from the .network throw, which nothing reads. Both HTTP error sites formatted the response body with the same 1024-char truncation; share it as HTTP.describeErrorBody. Messages are unchanged. Tests: pin that a 403 on any other error type stays terminal, and cover the wiring itself — requestValidation must attach the status, which the existing hand-built expectations cannot catch. That one goes through the test server, since URLProtocol.registerClass only intercepts URLSession.shared and HTTP uses its own session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pblazej
left a comment
There was a problem hiding this comment.
LGTM, test flakiness addressed elsewhere.
Part of CLT-3325. Companion PRs: client-sdk-js#2097, client-sdk-flutter#1200.
Problem
LiveKit Cloud enforces project-level region pinning by returning 403 on the RTC paths (
/rtc,/rtc/validate) when a project is not allowed in the region the client geo-routed to./settings/regionsis deliberately excluded from that gate so the client can discover its allowed regions and connect there — per the server-side comment, "allow other APIs to pass because clients will permanently give up if they fail."Swift blocked that recovery twice.
HTTP.requestValidationmaps any 4xx except 404/429 to.validation, andconnectWithCloudRegionFailovershort-circuited on it:and even without that,
isRetryableForRegionFailoveradmitted only.network/.timedOut. So a pinned project that geo-routed to a disallowed region gave up before ever fetching the region list.Fix
LiveKitErroras a new optionalstatusCode, set whereHTTP.requestValidationclassifies the response.isRetryableForRegionFailover..validationshort-circuit inconnectWithCloudRegionFailover, which is now redundant —.validationwithout a 403 still returnsfalseand throws exactly as before.nilresult fromRegionManager.resolveBestrather than a thrown.regionManager, soconnectWithCloudRegionFailoverrethrows the connection error it is already holding instead of replacing it. A throw fromresolveBeststill means a genuine settings fetch / HTTP / parse failure and propagates unchanged.applyFetchedRegions, matchingupdateFromServerReportedRegions.remainingis maintained.Net behaviour change: 403 → retry other regions; 401 stays terminal (no other region will accept the same token), as does the 404 that surfaces as
.serviceNotFoundfor the v1 → v0 path fallback.Why status and not the error message
The server's body for this case is
"project not allowed in this region.", but that is an unversioned human-readable string, and today it reaches Swift only embedded inmessageas"HTTP 403: ...". Matching either would mean five SDKs carrying identical literals forever, and a server-side copy edit would silently break already-shipped clients — worst of all on iOS, where a released app cannot be hot-patched. Hence the structuredstatusCode.The cost of not discriminating is bounded: if a 403 really was a permissions failure, every region attempt fails the same way and the original error still surfaces, one region lookup later.
That last part was not true as originally written, and review caught it (thanks @pblazej, Devin). Exhaustion threw
.regionManager("No more remaining regions.")from inside the samecatch, discarding the 403 — so a permission failure came back less informative than before this PR. Item 4 above is what actually makes the sentence hold — and follows rust-sdks, whoselivekit-signalingfallback trackslast_erracross region attempts and never synthesizes an exhaustion error. A settings fetch/parse failure surfaces the connection error too, matching rust's explicit choice not to mask it. Items 5 and 6 address a related pre-existing gap the wider retry set makes easier to hit:applyFetchedRegionsrefilledremainingon every refresh, so once per-region failure outlastscacheIntervalthe loop could never exhaust andRoom.connectwould hang rather than fail.API impact
None.
statusCodeis internal, and the publicLiveKitErrorinitializer keeps its exact signature; a separate internal initializer records the status.I first added a defaulted
statusCode:parameter to the existing public init, assuming that was source-compatible — it is for callers, but the Swift API digester reports the changed signature as a removed declaration, andCheck Public APIfailed on it. Fixed in 376d8da. ExposingstatusCodepublicly would be useful but is a separate decision that shouldn't ride along in a bugfix.Cross-SDK status
This bug is not universal. Rust and Android already fail over on any non-cancellation error and are unaffected; JS, Flutter and Swift all bail. Agents SDKs route through the Rust core, so agents are unaffected.
Testing
xcodebuild build -scheme LiveKit -destination 'platform=macOS'succeeds. New testregionPinning403IsRetryableButOtherValidationFailuresAreNotcovers 403 / 401 / 404; the existingregionManagerShouldRetryConnectionis unchanged and still passes.RegionManagerTestspasses in full (6 tests).LiveKitCoreTestsrun against a locallivekit-server --devleaves 3 failures —defineAndGetSchema,publishWithFrameMetadata,concurrentPush— all data-track schema tests, whichAGENTS.mddocuments as requiringenable_participant_data_blob: true; thelivekit-serverbuild available locally rejects that config key. I was not able to baseline these againstmain(the run exceeded my time budget), so I am flagging rather than asserting they are pre-existing — though this change only touches error classification on the region-failover path and has no plausible connection to data-track schemas.Open question
How often the RTC 403 actually fires for pinned projects has not been confirmed with the Cloud team — geo-routing may normally land clients in-region, making this an edge case (bad GeoDNS, anycast flap, VPN). The opt-in
prepareConnection()warm-up also does region selection up front and would mask it for apps that call it. The fix is correct either way and works against today's servers, but severity is unconfirmed.🤖 Generated with Claude Code