diff --git a/.changeset/clear-attempted-regions.md b/.changeset/clear-attempted-regions.md new file mode 100644 index 00000000..c1fccfc7 --- /dev/null +++ b/.changeset/clear-attempted-regions.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Fix region failover being skipped on later connects after a failed attempt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt index 12e0121e..42622a36 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt @@ -549,22 +549,30 @@ constructor( var nextUrl: String? = regionUrl ?: url regionUrl = null - while (nextUrl != null) { - val connectUrl = nextUrl - nextUrl = null - try { - engine.regionUrlProvider = regionUrlProvider - engine.join(connectUrl, token, options, roomOptions) - } catch (e: Exception) { - e.rethrowIfCancellationSignal() - - nextUrl = regionUrlProvider?.getNextBestRegionUrl() - if (nextUrl != null) { - LKLog.d(e) { "Connection to $connectUrl failed, retrying with another region: $nextUrl" } - } else { - throw e // rethrow since no more regions to try. + // Attempted regions are scoped to this failover cycle, since the provider is reused + // across connects. Captured locally as prepareConnection can replace the field. + val cycleRegionUrlProvider = regionUrlProvider + + try { + while (nextUrl != null) { + val connectUrl = nextUrl + nextUrl = null + try { + engine.regionUrlProvider = cycleRegionUrlProvider + engine.join(connectUrl, token, options, roomOptions) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + + nextUrl = cycleRegionUrlProvider?.getNextBestRegionUrl() + if (nextUrl != null) { + LKLog.d(e) { "Connection to $connectUrl failed, retrying with another region: $nextUrl" } + } else { + throw e // rethrow since no more regions to try. + } } } + } finally { + cycleRegionUrlProvider?.clearAttemptedRegions() } ensureActive() diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/RegionUrlProviderTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/RegionUrlProviderTest.kt index 30f60f7f..6cec4731 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/RegionUrlProviderTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/RegionUrlProviderTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 LiveKit, Inc. + * Copyright 2024-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. @@ -79,6 +79,29 @@ class RegionUrlProviderTest : BaseTest() { // Check that only one request was needed. assertEquals(1, server.requestCount) } + + /** + * The attempted set scopes one failover cycle. Once it is cleared the whole region list is + * eligible again, so a region that failed transiently is retried on the next cycle rather + * than being skipped for the life of the provider. + */ + @Test + fun clearAttemptedRegionsMakesEveryRegionEligibleAgain() = runTest { + server = MockWebServer() + server.enqueue(MockResponse().setBody(regionResponse)) + val regionUrlProvider = RegionUrlProvider(server.url("").toUri(), "token", OkHttpClient.Builder().build(), Json { ignoreUnknownKeys = true }) + + assertEquals("https://regiona.livekit.cloud", regionUrlProvider.getNextBestRegionUrl()) + assertEquals("https://regionb.livekit.cloud", regionUrlProvider.getNextBestRegionUrl()) + + regionUrlProvider.clearAttemptedRegions() + + // Back to the best region, not the next unattempted one. + assertEquals("https://regiona.livekit.cloud", regionUrlProvider.getNextBestRegionUrl()) + + // Still served from the cached settings. + assertEquals(1, server.requestCount) + } } private val regionResponse = """{