Skip to content

fix: fail over to other regions when Cloud rejects a connection with 403 - #1120

Merged
pblazej merged 8 commits into
mainfrom
sxian/CLT-3325/some-client-sdks-bail-on-region-pinning-403-insteadOf-failing-over
Sep 22, 2026
Merged

pblazej merged 8 commits into
mainfrom
sxian/CLT-3325/some-client-sdks-bail-on-region-pinning-403-insteadOf-failing-over

Conversation

@xianshijing-lk

@xianshijing-lk xianshijing-lk commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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/regions is 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.requestValidation maps any 4xx except 404/429 to .validation, and connectWithCloudRegionFailover short-circuited on it:

if let liveKitError = error as? LiveKitError, liveKitError.type == .validation {
    // Don't retry other regions for validation errors.
    throw liveKitError
}

and even without that, isRetryableForRegionFailover admitted only .network / .timedOut. So a pinned project that geo-routed to a disallowed region gave up before ever fetching the region list.

Fix

  1. Carry the HTTP status on LiveKitError as a new optional statusCode, set where HTTP.requestValidation classifies the response.
  2. Admit a 403 in isRetryableForRegionFailover.
  3. Drop the .validation short-circuit in connectWithCloudRegionFailover, which is now redundant — .validation without a 403 still returns false and throws exactly as before.
  4. Report region exhaustion as a nil result from RegionManager.resolveBest rather than a thrown .regionManager, so connectWithCloudRegionFailover rethrows the connection error it is already holding instead of replacing it. A throw from resolveBest still means a genuine settings fetch / HTTP / parse failure and propagates unchanged.
  5. Keep failed regions excluded across a settings refresh in applyFetchedRegions, matching updateFromServerReportedRegions.
  6. Cap the failover loop at 10 attempts, so termination does not rest on an invariant about how remaining is 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 .serviceNotFound for 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 in message as "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 structured statusCode.

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 same catch, 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, whose livekit-signaling fallback tracks last_err across 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: applyFetchedRegions refilled remaining on every refresh, so once per-region failure outlasts cacheInterval the loop could never exhaust and Room.connect would hang rather than fail.

API impact

None. statusCode is internal, and the public LiveKitError initializer 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, and Check Public API failed on it. Fixed in 376d8da. Exposing statusCode publicly 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 test regionPinning403IsRetryableButOtherValidationFailuresAreNot covers 403 / 401 / 404; the existing regionManagerShouldRetryConnection is unchanged and still passes. RegionManagerTests passes in full (6 tests).

LiveKitCoreTests run against a local livekit-server --dev leaves 3 failures — defineAndGetSchema, publishWithFrameMetadata, concurrentPush — all data-track schema tests, which AGENTS.md documents as requiring enable_participant_data_blob: true; the livekit-server build available locally rejects that config key. I was not able to baseline these against main (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

@github-actions

Copy link
Copy Markdown

⚠️ This PR does not contain any files in the .changes directory.

devin-ai-integration[bot]

This comment was marked as resolved.

Comment thread Sources/LiveKit/Core/Room+Region.swift
Comment thread Sources/LiveKit/Core/Room+Region.swift
xianshijing-lk and others added 3 commits September 21, 2026 10:39
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>
@xianshijing-lk
xianshijing-lk force-pushed the sxian/CLT-3325/some-client-sdks-bail-on-region-pinning-403-insteadOf-failing-over branch from 376d8da to 3c03b42 Compare September 21, 2026 18:06
devin-ai-integration[bot]

This comment was marked as resolved.

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>
devin-ai-integration[bot]

This comment was marked as resolved.

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>
devin-ai-integration[bot]

This comment was marked as resolved.

/// 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pblazej , what do you think about 10 attempt cap ? or we should do less?

@pblazej pblazej Sep 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

pblazej and others added 3 commits September 22, 2026 08:34
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 pblazej left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, test flakiness addressed elsewhere.

@pblazej
pblazej merged commit d6765cc into main Sep 22, 2026
29 of 33 checks passed
@pblazej
pblazej deleted the sxian/CLT-3325/some-client-sdks-bail-on-region-pinning-403-insteadOf-failing-over branch September 22, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants