Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Unreleased

* [IMPROVEMENT] Limit each Flags assignment request to one second and retry transient failures once by default. Add timeout and retry-count configuration.

# 3.12.1 / 2026-07-16

* [BUGFIX] Fix R8 failures due to missing `SourceLines` annotation. See [#3642](https://github.com/DataDog/dd-sdk-android/pull/3642)
Expand Down
11 changes: 11 additions & 0 deletions features/dd-sdk-android-flags/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ val flagsConfig = FlagsConfiguration.Builder()
.build()
```

#### Configure assignment request limits

Each assignment request has a one-second timeout and one retry by default. You can change both limits:

```kotlin
val flagsConfig = FlagsConfiguration.Builder()
.assignmentRequestTimeout(2_000)
.assignmentRequestRetryCount(2)
.build()
```

## Use the Feature Flags SDK

### Create a Flags client
Expand Down
2 changes: 2 additions & 0 deletions features/dd-sdk-android-flags/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ data class com.datadog.android.flags.FlagsConfiguration
fun useCustomEvaluationEndpoint(String): Builder
fun evaluationFlushInterval(Long): Builder
fun useCustomFlagEndpoint(String): Builder
fun assignmentRequestTimeout(Long): Builder
fun assignmentRequestRetryCount(Int): Builder
fun rumIntegrationEnabled(Boolean): Builder
fun gracefulModeEnabled(Boolean): Builder
fun build(): FlagsConfiguration
Expand Down
2 changes: 2 additions & 0 deletions features/dd-sdk-android-flags/api/dd-sdk-android-flags.api
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ public final class com/datadog/android/flags/FlagsConfiguration {

public final class com/datadog/android/flags/FlagsConfiguration$Builder {
public fun <init> ()V
public final fun assignmentRequestRetryCount (I)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun assignmentRequestTimeout (J)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun build ()Lcom/datadog/android/flags/FlagsConfiguration;
public final fun evaluationFlushInterval (J)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
public final fun gracefulModeEnabled (Z)Lcom/datadog/android/flags/FlagsConfiguration$Builder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,9 @@ interface FlagsClient {
val assignmentsDownloader = PrecomputedAssignmentsDownloader(
internalLogger = featureSdkCore.internalLogger,
callFactory = callFactory,
requestFactory = flagsFeature.precomputedRequestFactory
requestFactory = flagsFeature.precomputedRequestFactory,
requestTimeoutMs = configuration.assignmentRequestTimeoutMs,
requestRetryCount = configuration.assignmentRequestRetryCount
)

val precomputeMapper = PrecomputeMapper(featureSdkCore.internalLogger)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

package com.datadog.android.flags

private const val DEFAULT_ASSIGNMENT_REQUEST_TIMEOUT_MS = 1_000L
private const val DEFAULT_ASSIGNMENT_REQUEST_RETRY_COUNT = 1

/**
* Describes configuration to be used for the Flags feature.
*/
Expand All @@ -19,15 +22,21 @@ data class FlagsConfiguration internal constructor(
internal val rumIntegrationEnabled: Boolean,
internal val gracefulModeEnabled: Boolean
) {
internal var assignmentRequestTimeoutMs: Long = DEFAULT_ASSIGNMENT_REQUEST_TIMEOUT_MS
internal var assignmentRequestRetryCount: Int = DEFAULT_ASSIGNMENT_REQUEST_RETRY_COUNT

/**
* A Builder class for a [FlagsConfiguration].
*/
@Suppress("TooManyFunctions")
class Builder {
private var trackExposures: Boolean = true
private var trackEvaluations: Boolean = true
private var customExposureEndpoint: String? = null
private var customEvaluationEndpoint: String? = null
private var customFlagEndpoint: String? = null
private var assignmentRequestTimeoutMs: Long = DEFAULT_ASSIGNMENT_REQUEST_TIMEOUT_MS
private var assignmentRequestRetryCount: Int = DEFAULT_ASSIGNMENT_REQUEST_RETRY_COUNT
private var evaluationFlushIntervalMs: Long = DEFAULT_EVALUATION_FLUSH_INTERVAL_MS
private var rumIntegrationEnabled: Boolean = true
private var gracefulModeEnabled: Boolean = true
Expand Down Expand Up @@ -115,6 +124,34 @@ data class FlagsConfiguration internal constructor(
return this
}

/**
* Sets the timeout for each precomputed assignment request.
* Values less than or equal to zero use the default timeout of 1,000 milliseconds.
*
* @param timeoutMs The timeout for each request, in milliseconds.
* @return this [Builder] instance for method chaining.
*/
fun assignmentRequestTimeout(timeoutMs: Long): Builder {
assignmentRequestTimeoutMs = if (timeoutMs > 0) {
timeoutMs
} else {
DEFAULT_ASSIGNMENT_REQUEST_TIMEOUT_MS
}
return this
}

/**
* Sets the number of retries after a transient precomputed assignment request failure.
* Negative values are treated as zero.
*
* @param retryCount The number of retries after the first attempt.
* @return this [Builder] instance for method chaining.
*/
fun assignmentRequestRetryCount(retryCount: Int): Builder {
assignmentRequestRetryCount = retryCount.coerceAtLeast(0)
return this
}

/**
* Sets whether RUM evaluation logging is enabled.
* This adds the result of evaluating a feature flag to the view.
Expand Down Expand Up @@ -160,7 +197,10 @@ data class FlagsConfiguration internal constructor(
evaluationFlushIntervalMs = evaluationFlushIntervalMs,
rumIntegrationEnabled = rumIntegrationEnabled,
gracefulModeEnabled = gracefulModeEnabled
)
).also {
it.assignmentRequestTimeoutMs = assignmentRequestTimeoutMs
it.assignmentRequestRetryCount = assignmentRequestRetryCount
}

internal companion object {
private const val DEFAULT_EVALUATION_FLUSH_INTERVAL_MS = 10_000L // 10 seconds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,24 @@ import com.datadog.android.api.context.DatadogContext
import com.datadog.android.flags.model.EvaluationContext
import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit

/**
* Downloads precomputed flag assignments from Datadog Feature Flags service.
*
* @param callFactory Factory for creating HTTP calls
* @param internalLogger Logger for error and debug messages
* @param requestFactory Factory for creating precomputed assignments requests
* @param requestTimeoutMs Timeout for each request, in milliseconds
* @param requestRetryCount Number of retries after the first attempt
*/
internal class PrecomputedAssignmentsDownloader(
private val callFactory: Call.Factory,
private val internalLogger: InternalLogger,
private val requestFactory: PrecomputedAssignmentsRequestFactory
private val requestFactory: PrecomputedAssignmentsRequestFactory,
private val requestTimeoutMs: Long = 1_000L,
private val requestRetryCount: Int = 1
) : PrecomputedAssignmentsReader {

@WorkerThread
Expand All @@ -34,40 +39,84 @@ internal class PrecomputedAssignmentsDownloader(
return executeDownloadRequest(request)
}

@Suppress("TooGenericExceptionCaught")
private fun executeDownloadRequest(request: Request): String? = try {
val response = callFactory.newCall(request).execute()
handleResponse(response)
private fun executeDownloadRequest(request: Request): String? {
var attempt = 0
var result: DownloadResult
do {
result = executeSingleRequest(request)
attempt++
} while (result.isRetryable && attempt <= requestRetryCount)

return when (result) {
is DownloadResult.Success -> result.body
is DownloadResult.HttpFailure -> {
internalLogger.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{ "Failed to download flags: ${result.statusCode}" }
)
internalLogger.log(
level = InternalLogger.Level.ERROR,
target = InternalLogger.Target.TELEMETRY,
messageBuilder = { "Flag assignment server returned error (${result.statusCode})" },
onlyOnce = true
)
null
}
is DownloadResult.UnexpectedFailure -> {
internalLogger.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{ "Unexpected error while downloading flags" },
result.throwable
)
null
}
}
}

@Suppress("TooGenericExceptionCaught", "UnsafeThirdPartyFunctionCall")
private fun executeSingleRequest(request: Request): DownloadResult = try {
val call = callFactory.newCall(request)
call.timeout().timeout(requestTimeoutMs, TimeUnit.MILLISECONDS)
val response = call.execute()
if (response.isSuccessful) {
DownloadResult.Success(response.body?.use { it.string() })
} else {
val statusCode = response.code
response.body?.close()
DownloadResult.HttpFailure(statusCode, isRetryableStatus(statusCode))
}
} catch (e: IOException) {
DownloadResult.UnexpectedFailure(e, isRetryable = true)
} catch (e: Throwable) {
internalLogger.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{ "Unexpected error while downloading flags" },
e
)
null
DownloadResult.UnexpectedFailure(e, isRetryable = false)
}

private fun handleResponse(response: Response): String? = if (response.isSuccessful) {
@Suppress("UnsafeThirdPartyFunctionCall") // Safe: wrapped in outer try-catch
response.body?.use { it.string() }
} else {
internalLogger.log(
InternalLogger.Level.ERROR,
InternalLogger.Target.MAINTAINER,
{ "Failed to download flags: ${response.code}" }
)
private fun isRetryableStatus(statusCode: Int): Boolean =
statusCode == HTTP_REQUEST_TIMEOUT ||
statusCode == HTTP_TOO_MANY_REQUESTS ||
statusCode in HTTP_SERVER_ERROR_MIN..HTTP_SERVER_ERROR_MAX

internalLogger.log(
level = InternalLogger.Level.ERROR,
target = InternalLogger.Target.TELEMETRY,
messageBuilder = { "Flag assignment server returned error (${response.code})" },
onlyOnce = true
)
private sealed interface DownloadResult {
val isRetryable: Boolean

@Suppress("UnsafeThirdPartyFunctionCall") // Safe: wrapped in outer try-catch
response.body?.close()
data class Success(val body: String?) : DownloadResult {
override val isRetryable: Boolean = false
}

data class HttpFailure(val statusCode: Int, override val isRetryable: Boolean) : DownloadResult

data class UnexpectedFailure(
val throwable: Throwable,
override val isRetryable: Boolean
) : DownloadResult
}

null
private companion object {
const val HTTP_REQUEST_TIMEOUT = 408
const val HTTP_TOO_MANY_REQUESTS = 429
const val HTTP_SERVER_ERROR_MIN = 500
const val HTTP_SERVER_ERROR_MAX = 599
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ internal class FlagsConfigurationTest {
assertThat(configuration.customExposureEndpoint).isNull()
assertThat(configuration.customFlagEndpoint).isNull()
assertThat(configuration.gracefulModeEnabled).isTrue()
assertThat(configuration.assignmentRequestTimeoutMs).isEqualTo(1_000L)
assertThat(configuration.assignmentRequestRetryCount).isEqualTo(1)
}

@Test
Expand Down Expand Up @@ -142,5 +144,31 @@ internal class FlagsConfigurationTest {
assertThat(returnedBuilder).isSameAs(builder)
}

@Test
fun `M set assignment request limits W Builder`() {
// When
val configuration = FlagsConfiguration.Builder()
.assignmentRequestTimeout(2_500L)
.assignmentRequestRetryCount(3)
.build()

// Then
assertThat(configuration.assignmentRequestTimeoutMs).isEqualTo(2_500L)
assertThat(configuration.assignmentRequestRetryCount).isEqualTo(3)
}

@Test
fun `M sanitize invalid assignment request limits W Builder`() {
// When
val configuration = FlagsConfiguration.Builder()
.assignmentRequestTimeout(0)
.assignmentRequestRetryCount(-1)
.build()

// Then
assertThat(configuration.assignmentRequestTimeoutMs).isEqualTo(1_000L)
assertThat(configuration.assignmentRequestRetryCount).isZero()
}

// endregion
}
Loading
Loading