Skip to content

Commit d6ac0a4

Browse files
authored
fix: ignore cancellation exceptions (#134)
Signed-off-by: Nicklas Lundin <[email protected]>
1 parent 600b488 commit d6ac0a4

File tree

2 files changed

+65
-32
lines changed

2 files changed

+65
-32
lines changed

android/src/main/java/dev/openfeature/sdk/OpenFeatureAPI.kt

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dev.openfeature.sdk
22

33
import dev.openfeature.sdk.events.OpenFeatureProviderEvents
44
import dev.openfeature.sdk.exceptions.OpenFeatureError
5+
import kotlinx.coroutines.CancellationException
56
import kotlinx.coroutines.CoroutineDispatcher
67
import kotlinx.coroutines.CoroutineScope
78
import kotlinx.coroutines.Dispatchers
@@ -18,7 +19,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
1819
import kotlinx.coroutines.flow.filterIsInstance
1920
import kotlinx.coroutines.flow.flatMapLatest
2021
import kotlinx.coroutines.launch
21-
import java.util.concurrent.CancellationException
2222

2323
@Suppress("TooManyFunctions")
2424
object OpenFeatureAPI {
@@ -42,7 +42,6 @@ object OpenFeatureAPI {
4242
* A flow of [OpenFeatureStatus] that emits the current status of the SDK.
4343
*/
4444
val statusFlow: Flow<OpenFeatureStatus> get() = _statusFlow.distinctUntilChanged()
45-
private var providerJob: Job? = null
4645

4746
var hooks: List<Hook<*>> = listOf()
4847
private set
@@ -64,7 +63,7 @@ object OpenFeatureAPI {
6463
dispatcher: CoroutineDispatcher = Dispatchers.IO,
6564
initialContext: EvaluationContext? = null
6665
) {
67-
setProviderJob?.cancel()
66+
setProviderJob?.cancel(CancellationException("Provider set job was cancelled due to new provider"))
6867
this.setProviderJob = CoroutineScope(SupervisorJob() + dispatcher).launch {
6968
setProviderInternal(provider, dispatcher, initialContext)
7069
}
@@ -85,8 +84,8 @@ object OpenFeatureAPI {
8584
}
8685

8786
private fun listenToProviderEvents(provider: FeatureProvider, dispatcher: CoroutineDispatcher) {
88-
providerJob?.cancel()
89-
this.providerJob = CoroutineScope(SupervisorJob() + dispatcher).launch {
87+
observeProviderEventsJob?.cancel(CancellationException("Provider job was cancelled due to new provider"))
88+
this.observeProviderEventsJob = CoroutineScope(SupervisorJob() + dispatcher).launch {
9089
provider.observe().collect(handleProviderEvents)
9190
}
9291
}
@@ -105,20 +104,10 @@ object OpenFeatureAPI {
105104
}
106105
providersFlow.value = provider
107106
if (initialContext != null) context = initialContext
108-
try {
107+
tryWithStatusEmitErrorHandling {
109108
listenToProviderEvents(provider, dispatcher)
110109
getProvider().initialize(context)
111110
_statusFlow.emit(OpenFeatureStatus.Ready)
112-
} catch (e: OpenFeatureError) {
113-
_statusFlow.emit(OpenFeatureStatus.Error(e))
114-
} catch (e: Throwable) {
115-
_statusFlow.emit(
116-
OpenFeatureStatus.Error(
117-
OpenFeatureError.GeneralError(
118-
e.message ?: e.javaClass.name
119-
)
120-
)
121-
)
122111
}
123112
}
124113

@@ -171,7 +160,7 @@ object OpenFeatureAPI {
171160
evaluationContext: EvaluationContext,
172161
dispatcher: CoroutineDispatcher = Dispatchers.IO
173162
) {
174-
setEvaluationContextJob?.cancel()
163+
setEvaluationContextJob?.cancel(CancellationException("Set context job was cancelled due to new context"))
175164
this.setEvaluationContextJob = CoroutineScope(SupervisorJob() + dispatcher).launch {
176165
setEvaluationContextInternal(evaluationContext)
177166
}
@@ -182,20 +171,28 @@ object OpenFeatureAPI {
182171
context = evaluationContext
183172
if (oldContext != evaluationContext) {
184173
_statusFlow.emit(OpenFeatureStatus.Reconciling)
185-
try {
174+
tryWithStatusEmitErrorHandling {
186175
getProvider().onContextSet(oldContext, evaluationContext)
187176
_statusFlow.emit(OpenFeatureStatus.Ready)
188-
} catch (e: OpenFeatureError) {
189-
_statusFlow.emit(OpenFeatureStatus.Error(e))
190-
} catch (e: Throwable) {
191-
_statusFlow.emit(
192-
OpenFeatureStatus.Error(
193-
OpenFeatureError.GeneralError(
194-
e.message ?: e.javaClass.name
195-
)
177+
}
178+
}
179+
}
180+
181+
private suspend fun tryWithStatusEmitErrorHandling(function: suspend () -> Unit) {
182+
try {
183+
function()
184+
} catch (e: CancellationException) {
185+
// This happens by design and shouldn't be treated as an error
186+
} catch (e: OpenFeatureError) {
187+
_statusFlow.emit(OpenFeatureStatus.Error(e))
188+
} catch (e: Throwable) {
189+
_statusFlow.emit(
190+
OpenFeatureStatus.Error(
191+
OpenFeatureError.GeneralError(
192+
e.message ?: e.javaClass.name
196193
)
197194
)
198-
}
195+
)
199196
}
200197
}
201198

@@ -242,9 +239,11 @@ object OpenFeatureAPI {
242239
*/
243240
suspend fun shutdown() {
244241
clearHooks()
245-
setEvaluationContextJob?.cancel(CancellationException("Set context job was cancelled"))
246-
setProviderJob?.cancel(CancellationException("Provider set job was cancelled"))
247-
observeProviderEventsJob?.cancel(CancellationException("Provider event observe job was cancelled"))
242+
setEvaluationContextJob?.cancel(CancellationException("Set context job was cancelled due to shutdown"))
243+
setProviderJob?.cancel(CancellationException("Provider set job was cancelled due to shutdown"))
244+
observeProviderEventsJob?.cancel(
245+
CancellationException("Provider event observe job was cancelled due to shutdown")
246+
)
248247
providerEventObservationScope?.coroutineContext?.cancelChildren()
249248
providerEventObservationScope?.coroutineContext?.cancel()
250249
clearProvider()

android/src/test/java/dev/openfeature/sdk/StatusTests.kt

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ package dev.openfeature.sdk
22

33
import dev.openfeature.sdk.helpers.BrokenInitProvider
44
import dev.openfeature.sdk.helpers.DoSomethingProvider
5-
import junit.framework.Assert.assertEquals
6-
import junit.framework.Assert.assertTrue
5+
import dev.openfeature.sdk.helpers.SlowProvider
76
import kotlinx.coroutines.ExperimentalCoroutinesApi
87
import kotlinx.coroutines.cancelAndJoin
98
import kotlinx.coroutines.delay
109
import kotlinx.coroutines.launch
10+
import kotlinx.coroutines.test.StandardTestDispatcher
1111
import kotlinx.coroutines.test.TestScope
1212
import kotlinx.coroutines.test.advanceUntilIdle
1313
import kotlinx.coroutines.test.runTest
14+
import org.junit.Assert.assertEquals
15+
import org.junit.Assert.assertFalse
16+
import org.junit.Assert.assertTrue
1417
import org.junit.Before
1518
import org.junit.Test
19+
import kotlin.random.Random
20+
import kotlin.time.Duration
1621

1722
class StatusTests {
1823

@@ -73,8 +78,37 @@ class StatusTests {
7378
}
7479
job.cancelAndJoin()
7580
}
81+
82+
@Test
83+
fun testSpamSetContextWithoutAwait() = runTest {
84+
waitAssert {
85+
assertEquals(OpenFeatureStatus.NotReady, OpenFeatureAPI.getStatus())
86+
}
87+
val statuses = mutableListOf<OpenFeatureStatus>()
88+
val job = launch {
89+
OpenFeatureAPI.statusFlow.collect {
90+
statuses.add(it)
91+
}
92+
}
93+
val dispatcher = StandardTestDispatcher(testScheduler)
94+
OpenFeatureAPI.setProviderAndWait(SlowProvider(dispatcher = dispatcher))
95+
waitAssert { assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus()) }
96+
for (i in 1..30) {
97+
OpenFeatureAPI.setEvaluationContext(ImmutableContext("test_$i"))
98+
delay(Duration.randomMs(0, 10))
99+
}
100+
101+
waitAssert {
102+
assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus())
103+
}
104+
assertFalse(statuses.any { it is OpenFeatureStatus.Error })
105+
assertEquals(OpenFeatureStatus.Ready, OpenFeatureAPI.getStatus())
106+
job.cancelAndJoin()
107+
}
76108
}
77109

110+
private fun Duration.Companion.randomMs(min: Int, max: Int): Duration = Random.nextInt(min, max + 1).milliseconds
111+
78112
@OptIn(ExperimentalCoroutinesApi::class)
79113
suspend fun TestScope.waitAssert(timeoutMs: Long = 5000, function: () -> Unit) {
80114
var timeWaited = 0L

0 commit comments

Comments
 (0)