Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,19 @@ class DevCycleClient private constructor(
private var latestIdentifiedUser: PopulatedUser = user

private val variableInstanceMap: MutableMap<String, MutableMap<Any, WeakReference<Variable<*>>>> = mutableMapOf()
private val configUpdatedCallbacks = java.util.concurrent.CopyOnWriteArrayList<DevCycleCallback<Map<String, BaseConfigVariable>>>()

init {
useCachedConfigForUser(user)
val cacheHit = useCachedConfigForUser(user)

initializeJob = coroutineScope.async(coroutineContext) {
isExecuting.set(true)
try {
fetchConfig(user)
isInitialized.set(true)
withContext(Dispatchers.IO){
initEventSource()
val application : Application = context.applicationContext as Application
if (cacheHit) {
isInitialized.set(true)
initializeJob = CompletableDeferred(Unit)

coroutineScope.launch(coroutineContext) {
withContext(Dispatchers.IO) {
initEventSource()
val application: Application = context.applicationContext as Application
val lifecycleCallbacks = DVCLifecycleCallbacks(
onPauseApplication,
onResumeApplication,
Expand All @@ -98,17 +98,38 @@ class DevCycleClient private constructor(
)
application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
}

} catch (t: Throwable) {
DevCycleLogger.e(t, "DevCycle SDK Failed to Initialize!")
throw t
// Fetch fresh config in background (ADR 0009). SSE only fires on
// server-side changes, so an explicit fetch is needed to verify the cache.
performBackgroundRefresh()
}
} else {
initializeJob = coroutineScope.async(coroutineContext) {
isExecuting.set(true)
try {
fetchConfig(user)
isInitialized.set(true)
withContext(Dispatchers.IO) {
initEventSource()
val application: Application = context.applicationContext as Application
val lifecycleCallbacks = DVCLifecycleCallbacks(
onPauseApplication,
onResumeApplication,
config?.sse?.inactivityDelay?.toLong(),
customLifecycleHandler
)
application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
}
} catch (t: Throwable) {
DevCycleLogger.e(t, "DevCycle SDK Failed to Initialize!")
throw t
}
}
}

initializeJob.invokeOnCompletion {
coroutineScope.launch(coroutineContext) {
handleQueuedConfigRequests()
isExecuting.set(false)
initializeJob.invokeOnCompletion {
coroutineScope.launch(coroutineContext) {
handleQueuedConfigRequests()
isExecuting.set(false)
}
}
}
}
Expand Down Expand Up @@ -193,6 +214,8 @@ class DevCycleClient private constructor(
}
}

internal fun hasUsableCachedConfig(): Boolean = config != null && isConfigCached.get()

/**
* Updates or builds a new User and fetches the latest config for that User
*
Expand Down Expand Up @@ -233,12 +256,21 @@ class DevCycleClient private constructor(
override fun onError(error: Throwable) {
DevCycleLogger.d("Error fetching config for user_id %s: %s", updatedUser.userId, error.message)

if (error is DVCRequestException && (error.isAuthError || error.statusCode == 400)) {
dvcSharedPrefs.clearConfigForUser(updatedUser)
DevCycleLogger.w("Config error during identifyUser (${error.statusCode}). Persisted cache cleared.")
latestIdentifiedUser = previousUser
Comment on lines 256 to +262
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

PR description says a user switch "clears in-memory config immediately to prevent serving stale values for the wrong identity", but in identifyUser the existing in-memory config (and user used for evaluations/events) is kept until the async fetch completes. That means evaluations right after identifyUser can still be served from the previous user's config. If the intent is to stop serving stale values immediately on user switch, consider clearing/invalidating the in-memory config state (or gating evaluations) at the start of the identify flow.

Copilot uses AI. Check for mistakes.
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.

this should be fine, as we await for the config for the new identity to succeed before clearing cache

callback?.onError(error)
return
}
Comment thread
luxscious marked this conversation as resolved.

// In the event that the config request fails (i.e. the device is offline)
// Fallback to using a Cached Configuration for the User if available
val hasCachedConfig = tryLoadCachedConfigForUser(updatedUser)
if (hasCachedConfig) {
// Successfully used cached config, return success
config?.variables?.let { callback?.onSuccess(it) }
performBackgroundRefresh()
} else {
// No cached config available, restore previous state and return error
latestIdentifiedUser = previousUser
Expand Down Expand Up @@ -372,6 +404,11 @@ class DevCycleClient private constructor(
Variable.getAndValidateType(defaultValue)
val variable = this.getCachedVariable(key, defaultValue)

val currentEval = variable.eval
if (isConfigCached.get() && currentEval != null) {
variable.eval = EvalReason.withCachedSource(currentEval)
}

val tmpConfig = config
if(!disableAutomaticEventLogging){
val event: Event = Event.fromInternalEvent(
Expand Down Expand Up @@ -501,11 +538,15 @@ class DevCycleClient private constructor(
) {
val result = request.getConfigJson(sdkKey, user, enableEdgeDB, sse, lastModified, etag)
config = result
observable.configUpdated(config)
dvcSharedPrefs.saveConfig(result, user)
isConfigCached.set(false)
observable.configUpdated(config)
DevCycleLogger.d("A new config has been fetched for $user")

if (isInitialized.get()) {
notifyConfigUpdated(result.variables)
}

this@DevCycleClient.user = user

if (checkIfEdgeDBEnabled(result, enableEdgeDB)) {
Expand Down Expand Up @@ -582,14 +623,16 @@ class DevCycleClient private constructor(
}
}

private fun useCachedConfigForUser(user: PopulatedUser) {
private fun useCachedConfigForUser(user: PopulatedUser): Boolean {
val cachedConfig = if (disableConfigCache) null else dvcSharedPrefs.getConfig(user)
if (cachedConfig != null) {
config = cachedConfig
isConfigCached.set(true)
DevCycleLogger.d("Loaded config from cache for user_id %s", user.userId)
observable.configUpdated(config)
return true
}
return false
}

private fun tryLoadCachedConfigForUser(user: PopulatedUser): Boolean {
Expand All @@ -606,6 +649,56 @@ class DevCycleClient private constructor(
}
}

internal fun onConfigUpdated(callback: DevCycleCallback<Map<String, BaseConfigVariable>>) {
configUpdatedCallbacks.add(callback)
}

private fun notifyConfigUpdated(variables: Map<String, BaseConfigVariable>?) {
variables?.let { vars ->
configUpdatedCallbacks.forEach { callback ->
try {
callback.onSuccess(vars)
} catch (e: Exception) {
DevCycleLogger.e(e, "Error in config updated callback")
}
}
}
}

private fun notifyConfigError(error: Throwable) {
configUpdatedCallbacks.forEach { callback ->
try {
callback.onError(error)
} catch (e: Exception) {
DevCycleLogger.e(e, "Error in config error callback")
}
}
}

private fun performBackgroundRefresh() {
val userAtRefreshStart = latestIdentifiedUser
refetchConfig(false, null, null, object : DevCycleCallback<Map<String, BaseConfigVariable>> {
override fun onSuccess(result: Map<String, BaseConfigVariable>) {
DevCycleLogger.d("Background refresh succeeded")
}

override fun onError(error: Throwable) {
Comment on lines +681 to +685
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

In performBackgroundRefresh(), you capture userAtRefreshStart, but refetchConfig() always fetches using latestIdentifiedUser (and will queue using whatever latestIdentifiedUser is at queue-time). If the user changes concurrently (e.g., identifyUser runs while the refresh is being scheduled/queued), the refresh request may be executed for a different user than userAtRefreshStart, yet on error you clear the persisted cache for userAtRefreshStart. This can clear the wrong user's cache and misattribute PROVIDER_ERROR/config errors. Consider refactoring so background refresh fetches for a specific user (e.g., pass userAtRefreshStart into refetchConfig/fetchConfig) and uses that same user for any cache-clearing decisions.

Suggested change
override fun onSuccess(result: Map<String, BaseConfigVariable>) {
DevCycleLogger.d("Background refresh succeeded")
}
override fun onError(error: Throwable) {
override fun onSuccess(result: Map<String, BaseConfigVariable>) {
if (latestIdentifiedUser != userAtRefreshStart) {
DevCycleLogger.d("Background refresh completed after user changed; ignoring stale result")
return
}
DevCycleLogger.d("Background refresh succeeded")
}
override fun onError(error: Throwable) {
if (latestIdentifiedUser != userAtRefreshStart) {
DevCycleLogger.w("Background refresh failed after user changed; skipping stale cache/error handling.")
return
}

Copilot uses AI. Check for mistakes.
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.

I believe this is out of the scope of this PR. It's a pre-existing concurrency edge case in refetchConfig that exists independently of our changes

val isConfigError = error is DVCRequestException &&
(error.isAuthError || error.statusCode == 400)

if (isConfigError) {
dvcSharedPrefs.clearConfigForUser(userAtRefreshStart)
DevCycleLogger.w("Background refresh config error (${(error as DVCRequestException).statusCode}). Persisted cache cleared.")
if (configUpdatedCallbacks.isNotEmpty()) {
notifyConfigError(error)
}
} else {
DevCycleLogger.w("Background refresh failed: ${error.message}. Keeping caches.")
}
}
})
}

class DevCycleClientBuilder {
private var context: Context? = null
private var customLifecycleHandler: Handler? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ internal class Request constructor(sdkKey: String, apiBaseUrl: String, eventsBas
if (response.isSuccessful) {
return response.body() ?: throw Throwable("Unexpected result from API")
} else {
val httpResponseCode = HttpResponseCode.byCode(response.code())
val statusCode = response.code()
var errorResponse = ErrorResponse(listOf("Unknown Error"), null)

response.errorBody()?.let { errorBody ->
Expand All @@ -35,13 +35,13 @@ internal class Request constructor(sdkKey: String, apiBaseUrl: String, eventsBas
errorBody.string(),
ErrorResponse::class.java
)
throw DVCRequestException(httpResponseCode, errorResponse)
throw DVCRequestException(statusCode, errorResponse)
} catch (e: IOException) {
errorResponse = ErrorResponse(listOf(e.message ?: ""), null)
throw DVCRequestException(httpResponseCode, errorResponse)
throw DVCRequestException(statusCode, errorResponse)
}
}
throw DVCRequestException(httpResponseCode, errorResponse)
throw DVCRequestException(statusCode, errorResponse)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@ import com.devcycle.sdk.android.model.ErrorResponse
import com.devcycle.sdk.android.model.HttpResponseCode

class DVCRequestException(
private val httpResponseCode:HttpResponseCode,
private val errorResponse: ErrorResponse): Exception(errorResponse.message?.getOrNull(0)) {
val statusCode: Int,
private val errorResponse: ErrorResponse
): Exception(errorResponse.message?.getOrNull(0)) {

fun getHttpResponseCode(): HttpResponseCode {
return httpResponseCode
}
private val httpResponseCode = HttpResponseCode.byCode(statusCode)

fun getErrorResponse(): ErrorResponse {
return errorResponse
}
fun getHttpResponseCode(): HttpResponseCode = httpResponseCode

val isRetryable get() = httpResponseCode.code >= 500
fun getErrorResponse(): ErrorResponse = errorResponse

val isRetryable get() = statusCode == 429 || statusCode >= 500

val isAuthError get() = statusCode == 401 || statusCode == 403
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.devcycle.sdk.android.model

import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
Expand All @@ -26,8 +27,19 @@ data class EvalReason(
@get:Schema(required = false, description = "String that defines the target id for the evaluation")
@JsonProperty("target_id")
val targetId: String? = null,

/**
* Indicates the data source for this evaluation: "CACHED" when served from
* persistent cache, null when served from a live server response.
*/
@get:Schema(required = false, description = "Data source indicator: CACHED or null (live)")
@JsonIgnore
val source: String? = null,
) {
companion object {
fun defaultReason(details: String) = EvalReason("DEFAULT", details)

fun withCachedSource(original: EvalReason) =
original.copy(source = "CACHED")
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package com.devcycle.sdk.android.model

enum class HttpResponseCode(val code: Int) {
OK(200), ACCEPTED(201), BAD_REQUEST(400), UNAUTHORIZED(401), NOT_FOUND(404), SERVER_ERROR(500);
OK(200),
ACCEPTED(201),
BAD_REQUEST(400),
UNAUTHORIZED(401),
FORBIDDEN(403),
NOT_FOUND(404),
TOO_MANY_REQUESTS(429),
SERVER_ERROR(500);

companion object {
fun byCode(code: Int): HttpResponseCode {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import com.devcycle.sdk.android.model.DevCycleUser
import com.devcycle.sdk.android.model.Variable
import com.devcycle.sdk.android.util.DevCycleLogger
import dev.openfeature.kotlin.sdk.*
import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents
import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import org.json.JSONArray
import org.json.JSONObject
Expand All @@ -24,6 +28,8 @@ class DevCycleProvider(
override val metadata: ProviderMetadata = DevCycleProviderMetadata()
) : FeatureProvider {

private val _providerEvents = MutableSharedFlow<OpenFeatureProviderEvents>(extraBufferCapacity = 1)

/**
* The DevCycle client instance - created during initialization
*/
Expand Down Expand Up @@ -56,10 +62,16 @@ class DevCycleProvider(
}
}

val reason = when {
variable.isDefaulted == true -> Reason.DEFAULT.toString()
variable.eval?.source == "CACHED" -> "CACHED"
else -> variable.eval?.reason ?: Reason.TARGETING_MATCH.toString()
}

return ProviderEvaluation(
value = value,
variant = variable.key,
reason = variable.eval?.reason ?: if (variable.isDefaulted == true) Reason.DEFAULT.toString() else Reason.TARGETING_MATCH.toString(),
reason = reason,
metadata = if (hasMetadata) metadataBuilder.build() else EvaluationMetadata.EMPTY
)
}
Expand Down Expand Up @@ -104,7 +116,26 @@ class DevCycleProvider(

_devcycleClient = clientBuilder.build()

// Wait for DevCycle client to fully initialize
_devcycleClient!!.onConfigUpdated(object : DevCycleCallback<Map<String, BaseConfigVariable>> {
override fun onSuccess(result: Map<String, BaseConfigVariable>) {
_providerEvents.tryEmit(OpenFeatureProviderEvents.ProviderConfigurationChanged)
DevCycleLogger.d("Emitted PROVIDER_CONFIGURATION_CHANGED event")
}

override fun onError(t: Throwable) {
DevCycleLogger.e("Config error: ${t.message}")
_providerEvents.tryEmit(OpenFeatureProviderEvents.ProviderError(
OpenFeatureError.GeneralError(t.message ?: "Config error")
))
}
})

if (_devcycleClient!!.hasUsableCachedConfig()) {
DevCycleLogger.d("DevCycle OpenFeature provider initialized from cache (PROVIDER_READY)")
return
}
Comment thread
luxscious marked this conversation as resolved.

// Cache miss: block until network fetch completes
suspendCancellableCoroutine<Unit> { continuation ->
_devcycleClient!!.onInitialized(object : DevCycleCallback<String> {
override fun onSuccess(result: String) {
Expand Down Expand Up @@ -162,6 +193,8 @@ class DevCycleProvider(
}
}

override fun observe(): Flow<OpenFeatureProviderEvents> = _providerEvents.asSharedFlow()

override fun shutdown() {
_devcycleClient?.close()
}
Expand Down
Loading
Loading