From 6c1181d2c94a66ffa559af55c5fa03efa2ed52b5 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 3 Nov 2026 09:51:36 -0500 Subject: [PATCH 1/8] FFL-3565: Make Int reads of numeric flags lossless Read any numeric flag as Int when it is a whole number within Int range. Return TYPE_MISMATCH for fractional or out-of-range values, keep PARSE_ERROR for values that can't be parsed, and keep Double reads. Ref: FFL-3565 --- detekt_custom_safe_calls_third_party.yml | 5 + detekt_custom_unsafe_calls.yml | 1 + features/dd-sdk-android-flags/README.md | 2 + .../com/datadog/android/flags/FlagsClient.kt | 3 + .../flags/internal/FlagValueConverter.kt | 52 +++++- .../flags/internal/DatadogFlagsClientTest.kt | 169 +++++++++++++++++- 6 files changed, 225 insertions(+), 7 deletions(-) diff --git a/detekt_custom_safe_calls_third_party.yml b/detekt_custom_safe_calls_third_party.yml index 3a930d31d6..7f4b7a3b20 100644 --- a/detekt_custom_safe_calls_third_party.yml +++ b/detekt_custom_safe_calls_third_party.yml @@ -367,6 +367,10 @@ datadog: - "java.lang.StringBuilder.append(kotlin.String?)" - "java.lang.StringBuilder.clear()" - "java.lang.StringBuilder.constructor()" + - "java.math.BigDecimal.constructor(kotlin.Int)" + - "java.math.BigDecimal.scale()" + - "java.math.BigDecimal.signum()" + - "java.math.BigDecimal.toInt()" - "java.math.BigInteger.toHexString()" - "java.math.BigInteger.toLong()" - "java.nio.ByteBuffer.duplicate()" @@ -950,6 +954,7 @@ datadog: - "kotlin.String.removePrefix(kotlin.CharSequence)" - "kotlin.String.removeSuffix(kotlin.CharSequence)" - "kotlin.String.takeIf(kotlin.Function1)" + - "kotlin.String.toBigDecimalOrNull()" - "kotlin.String.toBigIntegerOrNull()" - "kotlin.String.toBoolean()" - "kotlin.String.toBooleanStrictOrNull()" diff --git a/detekt_custom_unsafe_calls.yml b/detekt_custom_unsafe_calls.yml index d201a28e77..ea2c4d36d1 100644 --- a/detekt_custom_unsafe_calls.yml +++ b/detekt_custom_unsafe_calls.yml @@ -167,6 +167,7 @@ datadog: - "java.lang.System.loadLibrary(kotlin.String?):java.lang.SecurityException,java.lang.UnsatisfiedLinkError,java.lang.NullPointerException" - "java.lang.reflect.Field.get(kotlin.Any):java.lang.IllegalAccessException,java.lang.IllegalArgumentException,java.lang.NullPointerException,java.lang.ExceptionInInitializerError" - "java.lang.reflect.Field.get(kotlin.Any?):java.lang.IllegalAccessException,java.lang.IllegalArgumentException,java.lang.NullPointerException,java.lang.ExceptionInInitializerError" + - "java.math.BigDecimal.stripTrailingZeros():java.lang.ArithmeticException" - "java.math.BigInteger.and(java.math.BigInteger?):java.lang.NumberFormatException,java.lang.ArithmeticException" - "java.math.BigInteger.constructor(kotlin.String?, kotlin.Int):java.lang.NumberFormatException,java.lang.ArithmeticException" - "java.math.BigInteger.shiftRight(kotlin.Int):java.lang.NumberFormatException,java.lang.ArithmeticException" diff --git a/features/dd-sdk-android-flags/README.md b/features/dd-sdk-android-flags/README.md index 60b8cdcc84..33b7505098 100644 --- a/features/dd-sdk-android-flags/README.md +++ b/features/dd-sdk-android-flags/README.md @@ -270,6 +270,8 @@ val maxRetries = client.resolveIntValue("max-retry-count", 3) val discountPercentage = client.resolveDoubleValue("discount-rate", 0.0) ``` +Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise it returns the default value: `resolve()` reports `PARSE_ERROR` when the value can't be parsed, and `TYPE_MISMATCH` for a parsed value that is fractional or outside `Int` range, such as `1.2`. + **Structured flags** ```kotlin val defaultConfig = JSONObject("""{"timeout": 30, "retries": 3}""") diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index a72855d0e6..fc5baaea97 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -139,6 +139,9 @@ interface FlagsClient { /** * Resolves an integer flag value. * + * A numeric flag also resolves when its value is a whole number within [Int] range, such as `5.0`. A fractional + * value, such as `1.2`, returns the default value. + * * @param flagKey The unique identifier of the flag to resolve. * @param defaultValue The value to return if the flag cannot be retrieved or parsed. * @return The integer value of the flag, or the default value if unavailable. diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt index a3c7e5b22a..035b501289 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt @@ -8,6 +8,7 @@ package com.datadog.android.flags.internal import com.datadog.android.flags.internal.model.VariationType import org.json.JSONObject +import java.math.BigDecimal import java.util.Locale import kotlin.reflect.KClass @@ -28,6 +29,9 @@ internal class TypeMismatchException(message: String) : Exception(message) */ internal object FlagValueConverter { + private val INT_MIN = BigDecimal(Int.MIN_VALUE) + private val INT_MAX = BigDecimal(Int.MAX_VALUE) + /** * Converts a flag's string value to the expected type. * @@ -36,7 +40,8 @@ internal object FlagValueConverter { * @param variationType The type specified in the flag metadata * @param targetType The target type class to convert to * @return Result containing the converted value on success, - * or a Failure with TypeMismatchException if types are incompatible, + * or a Failure with TypeMismatchException if types are incompatible + * or a numeric value can't be read as Int without loss, * or a Failure with the parse exception if conversion failed */ @Suppress("UNCHECKED_CAST") @@ -53,7 +58,7 @@ internal object FlagValueConverter { val result: T? = when (targetType) { Boolean::class -> variationValue.lowercase(Locale.US).toBooleanStrictOrNull() as? T String::class -> variationValue as T - Int::class -> variationValue.toIntOrNull() as? T + Int::class -> numberToInt(variationValue, variationType) as? T Double::class -> variationValue.toDoubleOrNull() as? T Map::class -> variationValue.toMap() as? T JSONObject::class -> { @@ -88,10 +93,7 @@ internal object FlagValueConverter { fun isTypeCompatible(variationType: String, targetType: KClass<*>): Boolean = when (targetType) { Boolean::class -> variationType == VariationType.BOOLEAN.value String::class -> variationType == VariationType.STRING.value - Int::class -> variationType == VariationType.INTEGER.value || variationType == VariationType.NUMBER.value - Double::class -> - variationType == VariationType.NUMBER.value || variationType == VariationType.FLOAT.value || - variationType == VariationType.INTEGER.value + Int::class, Double::class -> isNumeric(variationType) JSONObject::class -> variationType == VariationType.OBJECT.value Map::class -> variationType == VariationType.OBJECT.value else -> { @@ -115,4 +117,42 @@ internal object FlagValueConverter { Map::class -> "Map" else -> targetType.simpleName ?: "Unknown" } + + private fun isNumeric(variationType: String): Boolean = variationType == VariationType.INTEGER.value || + variationType == VariationType.NUMBER.value || + variationType == VariationType.FLOAT.value + + /** + * Int reads are lossless: a whole value within Int range, such as `5` or `5.0`, reads as Int whatever the + * numeric type. A fractional or out-of-range value is a type mismatch. A value that can't be parsed returns null, + * which [convert] reports as a parse error. + */ + private fun numberToInt(variationValue: String, variationType: String): Int? = variationValue.toIntOrNull() + ?: variationValue.toBigDecimalOrNull()?.let { decimalToInt(it, variationValue, variationType) } + + private fun decimalToInt(decimal: BigDecimal, variationValue: String, variationType: String): Int { + val problem = when { + isFractional(decimal) -> "fractional value '$variationValue'" + decimal < INT_MIN || decimal > INT_MAX -> "value '$variationValue' outside Int range" + else -> null + } + if (problem != null) { + @Suppress("ThrowingInternalException") // Caught by runCatching in convert() + throw TypeMismatchException("Flag has type '$variationType' with $problem but Int was requested") + } + return decimal.toInt() + } + + /** + * A value with a scale of 0 or less is whole. Check the scale first: it also prevents + * [BigDecimal.stripTrailingZeros] from overflowing the scale for a value such as `100e2147483647`. + * Zero is whole at any scale: on Android 9 and lower, [BigDecimal.stripTrailingZeros] returns a zero + * such as `0.0` unchanged. + */ + private fun isFractional(decimal: BigDecimal): Boolean { + if (decimal.scale() <= 0 || decimal.signum() == 0) return false + @Suppress("UnsafeThirdPartyFunctionCall") // Safe: the scale is positive, so it can't overflow + val stripped = decimal.stripTrailingZeros() + return stripped.scale() > 0 + } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index ea34486017..d17d25ff81 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -760,6 +760,153 @@ internal class DatadogFlagsClientTest { verifyNoInteractions(mockRumEvaluationLogger) } + @ParameterizedTest + @CsvSource( + "number, 5, 5", + "number, 5.0, 5", + "number, 1e3, 1000", + "number, -7.00, -7", + "number, 0.0, 0", + "number, -0.0, 0", + "number, 2147483647.0, 2147483647", + "number, -2147483648, -2147483648", + "number, -2147483648.0, -2147483648", + "integer, 7, 7", + "integer, 5.0, 5", + "float, 5, 5", + "float, 1e3, 1000" + ) + fun `M return value W resolve() { numeric flag, whole value, Int default }`( + fakeVariationType: String, + fakeVariationValue: String, + expectedValue: Int, + forge: Forge + ) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.anInt(min = 2000) + val fakeFlag = forge.getForgery().copy( + variationType = fakeVariationType, + variationValue = fakeVariationValue + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + Pair(fakeFlag, fakeDefaultContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(expectedValue) + assertThat(result.errorCode).isNull() + } + + @ParameterizedTest + @CsvSource( + "number, 1.2", + "number, -0.5", + "number, 5.000001", + "number, 999999999999999.99", + "number, 1e-400", + "float, 1.2", + "integer, 1.2" + ) + fun `M return default with TYPE_MISMATCH W resolve() { numeric flag, fractional value, Int default }`( + fakeVariationType: String, + fakeVariationValue: String, + forge: Forge + ) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.anInt() + val fakeFlag = forge.getForgery().copy( + variationType = fakeVariationType, + variationValue = fakeVariationValue + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + Pair(fakeFlag, fakeDefaultContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeDefaultValue) + assertThat(result.errorCode).isEqualTo(ErrorCode.TYPE_MISMATCH) + assertThat(result.errorMessage).isEqualTo( + "Flag '$fakeFlagKey': Flag has type '$fakeVariationType' with fractional value " + + "'$fakeVariationValue' but Int was requested" + ) + verifyNoInteractions(mockProcessor) + } + + @ParameterizedTest + @CsvSource( + "number, 2147483648", + "number, 3000000000", + "number, 1e20", + "number, -2147483649", + "number, -1e20", + "number, 100e2147483647", + "integer, 2147483648", + "float, 2147483648.0" + ) + fun `M return default with TYPE_MISMATCH W resolve() { numeric flag, value outside Int range, Int default }`( + fakeVariationType: String, + fakeVariationValue: String, + forge: Forge + ) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.anInt() + val fakeFlag = forge.getForgery().copy( + variationType = fakeVariationType, + variationValue = fakeVariationValue + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + Pair(fakeFlag, fakeDefaultContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeDefaultValue) + assertThat(result.errorCode).isEqualTo(ErrorCode.TYPE_MISMATCH) + assertThat(result.errorMessage).isEqualTo( + "Flag '$fakeFlagKey': Flag has type '$fakeVariationType' with value " + + "'$fakeVariationValue' outside Int range but Int was requested" + ) + } + + @ParameterizedTest + @CsvSource( + "number, not-a-number", + "integer, not-a-number", + "float, not-a-number" + ) + fun `M return default with PARSE_ERROR W resolve() { malformed numeric value, Int default }`( + fakeVariationType: String, + fakeVariationValue: String, + forge: Forge + ) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.anInt() + val fakeFlag = forge.getForgery().copy( + variationType = fakeVariationType, + variationValue = fakeVariationValue + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + Pair(fakeFlag, fakeDefaultContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeDefaultValue) + assertThat(result.errorCode).isEqualTo(ErrorCode.PARSE_ERROR) + assertThat(result.errorMessage) + .isEqualTo("Flag '$fakeFlagKey': Failed to parse value '$fakeVariationValue' as Int") + } + // endregion // region resolveDoubleValue() @@ -795,6 +942,27 @@ internal class DatadogFlagsClientTest { verifyNoInteractions(mockRumEvaluationLogger) } + @Test + fun `M return value W resolve() { number flag, JSON integer value, Double default }`(forge: Forge) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeFlagValue = forge.anInt() + val fakeDefaultValue = forge.aDouble() + val fakeFlag = forge.getForgery().copy( + variationType = VariationType.NUMBER.value, + variationValue = fakeFlagValue.toString() + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + Pair(fakeFlag, fakeDefaultContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeFlagValue.toDouble()) + assertThat(result.errorCode).isNull() + } + // endregion // region resolveStructureValue() @@ -1653,7 +1821,6 @@ internal class DatadogFlagsClientTest { val result = testedClient.resolveIntValue(fakeFlagKey, fakeDefaultValue) // Then - // toIntOrNull() returns null for values outside Int range, so default is returned assertThat(result).isEqualTo(fakeDefaultValue) } From e5cd0087f547d5cf6d568f39f1f51d7812e205ee Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 3 Nov 2026 19:04:36 -0500 Subject: [PATCH 2/8] FFL-3565: Apply review fixes to lossless Int reads Cap decimal parsing at 64 characters, add non-numeric, boundary and single-fit tests, and correct the README and KDoc. Unparseable values of every numeric type, including "float", return PARSE_ERROR. Ref: FFL-3565 --- features/dd-sdk-android-flags/README.md | 4 +- .../com/datadog/android/flags/FlagsClient.kt | 5 +- .../flags/internal/FlagValueConverter.kt | 20 ++- .../flags/internal/DatadogFlagsClientTest.kt | 133 +++++++++++++++--- .../integration/EvaluationTelemetryTest.kt | 3 +- .../flags/integration/FlagsEvaluationTest.kt | 3 + 6 files changed, 134 insertions(+), 34 deletions(-) diff --git a/features/dd-sdk-android-flags/README.md b/features/dd-sdk-android-flags/README.md index 33b7505098..0e095f57ad 100644 --- a/features/dd-sdk-android-flags/README.md +++ b/features/dd-sdk-android-flags/README.md @@ -270,7 +270,7 @@ val maxRetries = client.resolveIntValue("max-retry-count", 3) val discountPercentage = client.resolveDoubleValue("discount-rate", 0.0) ``` -Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise it returns the default value: `resolve()` reports `PARSE_ERROR` when the value can't be parsed, and `TYPE_MISMATCH` for a parsed value that is fractional or outside `Int` range, such as `1.2`. +Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise it returns the default value: `resolve()` reports `PARSE_ERROR` when the value can't be parsed or is a decimal value longer than 64 characters, and `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`. **Structured flags** ```kotlin @@ -329,7 +329,7 @@ if (result.errorCode != null) { ##### Error codes - `FLAG_NOT_FOUND` - The flag could not be found - `PARSE_ERROR` - Error parsing the flag value -- `TYPE_MISMATCH` - The flag type doesn't match the expected type +- `TYPE_MISMATCH` - The type of the flag value doesn't match the expected type, such as a fractional value read with `resolveIntValue` - `TARGETING_KEY_MISSING` - No targeting key was provided - `INVALID_CONTEXT` - The evaluation context is invalid - `PROVIDER_NOT_READY` - The provider is not yet ready diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index fc5baaea97..9363582758 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -139,8 +139,9 @@ interface FlagsClient { /** * Resolves an integer flag value. * - * A numeric flag also resolves when its value is a whole number within [Int] range, such as `5.0`. A fractional - * value, such as `1.2`, returns the default value. + * Any numeric flag resolves when its value is a whole number within [Int] range, such as `5` or `5.0`. A fractional + * value, such as `1.2`, a value outside [Int] range, or a decimal value longer than 64 characters returns the + * default value. * * @param flagKey The unique identifier of the flag to resolve. * @param defaultValue The value to return if the flag cannot be retrieved or parsed. diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt index 035b501289..79e6a84085 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt @@ -32,6 +32,9 @@ internal object FlagValueConverter { private val INT_MIN = BigDecimal(Int.MIN_VALUE) private val INT_MAX = BigDecimal(Int.MAX_VALUE) + // The longest decimal value that Int reads parse. This bounds the BigDecimal work on each read. + private const val MAX_DECIMAL_LENGTH = 64 + /** * Converts a flag's string value to the expected type. * @@ -58,7 +61,7 @@ internal object FlagValueConverter { val result: T? = when (targetType) { Boolean::class -> variationValue.lowercase(Locale.US).toBooleanStrictOrNull() as? T String::class -> variationValue as T - Int::class -> numberToInt(variationValue, variationType) as? T + Int::class -> parseLosslessInt(variationValue, variationType) as? T Double::class -> variationValue.toDoubleOrNull() as? T Map::class -> variationValue.toMap() as? T JSONObject::class -> { @@ -125,12 +128,15 @@ internal object FlagValueConverter { /** * Int reads are lossless: a whole value within Int range, such as `5` or `5.0`, reads as Int whatever the * numeric type. A fractional or out-of-range value is a type mismatch. A value that can't be parsed returns null, - * which [convert] reports as a parse error. + * which [convert] reports as a parse error. So does a value that is not a plain Int and is longer than + * [MAX_DECIMAL_LENGTH] characters. */ - private fun numberToInt(variationValue: String, variationType: String): Int? = variationValue.toIntOrNull() - ?: variationValue.toBigDecimalOrNull()?.let { decimalToInt(it, variationValue, variationType) } + private fun parseLosslessInt(variationValue: String, variationType: String): Int? = variationValue.toIntOrNull() + ?: variationValue.takeIf { it.length <= MAX_DECIMAL_LENGTH } + ?.toBigDecimalOrNull() + ?.let { requireLosslessInt(it, variationValue, variationType) } - private fun decimalToInt(decimal: BigDecimal, variationValue: String, variationType: String): Int { + private fun requireLosslessInt(decimal: BigDecimal, variationValue: String, variationType: String): Int { val problem = when { isFractional(decimal) -> "fractional value '$variationValue'" decimal < INT_MIN || decimal > INT_MAX -> "value '$variationValue' outside Int range" @@ -146,8 +152,8 @@ internal object FlagValueConverter { /** * A value with a scale of 0 or less is whole. Check the scale first: it also prevents * [BigDecimal.stripTrailingZeros] from overflowing the scale for a value such as `100e2147483647`. - * Zero is whole at any scale: on Android 9 and lower, [BigDecimal.stripTrailingZeros] returns a zero - * such as `0.0` unchanged. + * Zero is whole at any scale: AOSP libcore before Android 10 returns a zero such as `0.0` unchanged from + * [BigDecimal.stripTrailingZeros]. */ private fun isFractional(decimal: BigDecimal): Boolean { if (decimal.scale() <= 0 || decimal.signum() == 0) return false diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index d17d25ff81..bc9b9118e2 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -27,6 +27,7 @@ import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.FlagsClientEvent import com.datadog.android.flags.model.ResolutionReason import com.datadog.android.flags.utils.forge.ForgeConfigurator +import com.datadog.android.utils.verifyLog import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -774,7 +775,10 @@ internal class DatadogFlagsClientTest { "integer, 7, 7", "integer, 5.0, 5", "float, 5, 5", - "float, 1e3, 1000" + "float, 1e3, 1000", + "number, 0e5, 0", + "number, 10e-1, 1", + "number, 1.5e1, 15" ) fun `M return value W resolve() { numeric flag, whole value, Int default }`( fakeVariationType: String, @@ -820,7 +824,8 @@ internal class DatadogFlagsClientTest { val fakeDefaultValue = forge.anInt() val fakeFlag = forge.getForgery().copy( variationType = fakeVariationType, - variationValue = fakeVariationValue + variationValue = fakeVariationValue, + doLog = true ) whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn Pair(fakeFlag, fakeDefaultContext) @@ -846,6 +851,7 @@ internal class DatadogFlagsClientTest { "number, -2147483649", "number, -1e20", "number, 100e2147483647", + "number, 1e2147483648", "integer, 2147483648", "float, 2147483648.0" ) @@ -859,7 +865,8 @@ internal class DatadogFlagsClientTest { val fakeDefaultValue = forge.anInt() val fakeFlag = forge.getForgery().copy( variationType = fakeVariationType, - variationValue = fakeVariationValue + variationValue = fakeVariationValue, + doLog = true ) whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn Pair(fakeFlag, fakeDefaultContext) @@ -874,13 +881,25 @@ internal class DatadogFlagsClientTest { "Flag '$fakeFlagKey': Flag has type '$fakeVariationType' with value " + "'$fakeVariationValue' outside Int range but Int was requested" ) + verifyNoInteractions(mockProcessor) } @ParameterizedTest @CsvSource( "number, not-a-number", "integer, not-a-number", - "float, not-a-number" + "float, not-a-number", + "number, ''", + "number, ' 5'", + "number, NaN", + "number, Infinity", + "number, 5f", + "number, 0x10", + "number, 1e99999999999", + "number, 1e-2147483648", + "number, 1e2147483649", + "number, 10000000000000000000000000000000000000000000000000000000000000000.0", + "float, NaN" ) fun `M return default with PARSE_ERROR W resolve() { malformed numeric value, Int default }`( fakeVariationType: String, @@ -892,7 +911,8 @@ internal class DatadogFlagsClientTest { val fakeDefaultValue = forge.anInt() val fakeFlag = forge.getForgery().copy( variationType = fakeVariationType, - variationValue = fakeVariationValue + variationValue = fakeVariationValue, + doLog = true ) whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn Pair(fakeFlag, fakeDefaultContext) @@ -905,6 +925,35 @@ internal class DatadogFlagsClientTest { assertThat(result.errorCode).isEqualTo(ErrorCode.PARSE_ERROR) assertThat(result.errorMessage) .isEqualTo("Flag '$fakeFlagKey': Failed to parse value '$fakeVariationValue' as Int") + verifyNoInteractions(mockProcessor) + } + + @ParameterizedTest + @ValueSource(strings = ["boolean", "string", "object"]) + fun `M return default with TYPE_MISMATCH W resolve() { non-numeric flag, Int default }`( + fakeVariationType: String, + forge: Forge + ) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.anInt(min = 6) + val fakeFlag = forge.getForgery().copy( + variationType = fakeVariationType, + variationValue = "5", + doLog = true + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + Pair(fakeFlag, fakeDefaultContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeDefaultValue) + assertThat(result.errorCode).isEqualTo(ErrorCode.TYPE_MISMATCH) + assertThat(result.errorMessage) + .isEqualTo("Flag '$fakeFlagKey': Flag has type '$fakeVariationType' but Int was requested") + verifyNoInteractions(mockProcessor) } // endregion @@ -942,15 +991,26 @@ internal class DatadogFlagsClientTest { verifyNoInteractions(mockRumEvaluationLogger) } - @Test - fun `M return value W resolve() { number flag, JSON integer value, Double default }`(forge: Forge) { + @ParameterizedTest + @CsvSource( + "number, 5, 5.0", + "number, 1.5, 1.5", + "integer, 7, 7.0", + "integer, 5.0, 5.0", + "float, 5, 5.0" + ) + fun `M return value W resolve() { numeric flag, Double default }`( + fakeVariationType: String, + fakeVariationValue: String, + expectedValue: Double, + forge: Forge + ) { // Given val fakeFlagKey = forge.anAlphabeticalString() - val fakeFlagValue = forge.anInt() - val fakeDefaultValue = forge.aDouble() + val fakeDefaultValue = forge.aDouble(min = 100.0) val fakeFlag = forge.getForgery().copy( - variationType = VariationType.NUMBER.value, - variationValue = fakeFlagValue.toString() + variationType = fakeVariationType, + variationValue = fakeVariationValue ) whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn Pair(fakeFlag, fakeDefaultContext) @@ -959,10 +1019,38 @@ internal class DatadogFlagsClientTest { val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) // Then - assertThat(result.value).isEqualTo(fakeFlagValue.toDouble()) + assertThat(result.value).isEqualTo(expectedValue) assertThat(result.errorCode).isNull() } + @ParameterizedTest + @ValueSource(strings = ["boolean", "string", "object"]) + fun `M return default with TYPE_MISMATCH W resolve() { non-numeric flag, Double default }`( + fakeVariationType: String, + forge: Forge + ) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.aDouble(min = 6.0) + val fakeFlag = forge.getForgery().copy( + variationType = fakeVariationType, + variationValue = "5", + doLog = true + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + Pair(fakeFlag, fakeDefaultContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeDefaultValue) + assertThat(result.errorCode).isEqualTo(ErrorCode.TYPE_MISMATCH) + assertThat(result.errorMessage) + .isEqualTo("Flag '$fakeFlagKey': Flag has type '$fakeVariationType' but Double was requested") + verifyNoInteractions(mockProcessor) + } + // endregion // region resolveStructureValue() @@ -1800,28 +1888,29 @@ internal class DatadogFlagsClientTest { } @Test - fun `M return default value W resolveIntValue() { value exceeds Int MAX_VALUE }`(forge: Forge) { + fun `M log warning and return default W resolveIntValue() { number flag, fractional value }`(forge: Forge) { // Given val fakeFlagKey = forge.anAlphabeticalString() val fakeDefaultValue = forge.anInt() - // Value that's too large for Int - val oversizedValue = Int.MAX_VALUE.toLong() + 1L val fakeFlag = forge.getForgery().copy( - variationType = VariationType.INTEGER.value, - variationValue = oversizedValue.toString() - ) - val fakeContext = EvaluationContext( - targetingKey = forge.anAlphabeticalString(), - attributes = emptyMap() + variationType = VariationType.NUMBER.value, + variationValue = "1.2", + doLog = true ) whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn - Pair(fakeFlag, fakeContext) + Pair(fakeFlag, fakeDefaultContext) // When val result = testedClient.resolveIntValue(fakeFlagKey, fakeDefaultValue) // Then assertThat(result).isEqualTo(fakeDefaultValue) + verifyNoInteractions(mockProcessor) + mockInternalLogger.verifyLog( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + "Flag '$fakeFlagKey': Flag has type 'number' with fractional value '1.2' but Int was requested" + ) } // endregion diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt index e90d8d1ea7..3a746da16c 100644 --- a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/EvaluationTelemetryTest.kt @@ -681,7 +681,8 @@ internal class EvaluationTelemetryTest { listOf( Arguments.of("missing", false, ErrorCode.FLAG_NOT_FOUND, optedOut), Arguments.of("enabled", "fallback", ErrorCode.TYPE_MISMATCH, optedOut), - Arguments.of("malformed", false, ErrorCode.PARSE_ERROR, optedOut) + Arguments.of("malformed", false, ErrorCode.PARSE_ERROR, optedOut), + Arguments.of("number", 0, ErrorCode.TYPE_MISMATCH, optedOut) ) } diff --git a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsEvaluationTest.kt b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsEvaluationTest.kt index 911fb2f664..5af4a80210 100644 --- a/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsEvaluationTest.kt +++ b/reliability/single-fit/flags/src/test/kotlin/com/datadog/android/flags/integration/FlagsEvaluationTest.kt @@ -127,6 +127,7 @@ class FlagsEvaluationTest { "string" to assignment("string", fakeString), "int" to assignment("integer", fakeInt), "double" to assignment("number", fakeDouble), + "float" to assignment("float", fakeInt), "object" to assignment("object", JSONObject().put(fakeStructureKey, fakeStructureValue)) ) ) @@ -141,6 +142,8 @@ class FlagsEvaluationTest { assertThat(client.resolveStringValue("string", "")).isEqualTo(fakeString) assertThat(client.resolveIntValue("int", fakeInt.inv())).isEqualTo(fakeInt) assertThat(client.resolveDoubleValue("double", fakeDouble + 1)).isEqualTo(fakeDouble) + assertThat(client.resolveIntValue("float", fakeInt.inv())).isEqualTo(fakeInt) + assertThat(client.resolveDoubleValue("int", fakeInt.inv().toDouble())).isEqualTo(fakeInt.toDouble()) assertThat(client.resolveStructureValue("object", JSONObject()).getString(fakeStructureKey)) .isEqualTo(fakeStructureValue) assertThat(client.resolveStructureValue("object", emptyMap())) From 64be54daa18c4ec07edca82817cb7d4d7ff2d1f6 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 3 Nov 2026 19:30:55 -0500 Subject: [PATCH 3/8] FFL-3565: Pin the Int parse length limit and correct its docs Add 64- and 65-character boundary rows for the Int parse length limit, describe the limit precisely in the README and resolveIntValue KDoc, and reword the zero-scale comment to match observed Android behavior. Ref: FFL-3565 --- features/dd-sdk-android-flags/README.md | 2 +- .../src/main/kotlin/com/datadog/android/flags/FlagsClient.kt | 4 ++-- .../com/datadog/android/flags/internal/FlagValueConverter.kt | 2 +- .../datadog/android/flags/internal/DatadogFlagsClientTest.kt | 4 +++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/features/dd-sdk-android-flags/README.md b/features/dd-sdk-android-flags/README.md index 0e095f57ad..f8fc201ca0 100644 --- a/features/dd-sdk-android-flags/README.md +++ b/features/dd-sdk-android-flags/README.md @@ -270,7 +270,7 @@ val maxRetries = client.resolveIntValue("max-retry-count", 3) val discountPercentage = client.resolveDoubleValue("discount-rate", 0.0) ``` -Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise it returns the default value: `resolve()` reports `PARSE_ERROR` when the value can't be parsed or is a decimal value longer than 64 characters, and `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`. +Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise it returns the default value: `resolve()` reports `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`, and `PARSE_ERROR` when the value can't be parsed. To bound parsing work, a value longer than 64 characters is not parsed unless it is a plain integer within `Int` range, so it reports `PARSE_ERROR`. **Structured flags** ```kotlin diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index 9363582758..a6302e2bff 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -140,8 +140,8 @@ interface FlagsClient { * Resolves an integer flag value. * * Any numeric flag resolves when its value is a whole number within [Int] range, such as `5` or `5.0`. A fractional - * value, such as `1.2`, a value outside [Int] range, or a decimal value longer than 64 characters returns the - * default value. + * value, such as `1.2`, or a value outside [Int] range returns the default value. So does a value longer than + * 64 characters, unless it is a plain integer within [Int] range. * * @param flagKey The unique identifier of the flag to resolve. * @param defaultValue The value to return if the flag cannot be retrieved or parsed. diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt index 79e6a84085..a075472018 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt @@ -152,7 +152,7 @@ internal object FlagValueConverter { /** * A value with a scale of 0 or less is whole. Check the scale first: it also prevents * [BigDecimal.stripTrailingZeros] from overflowing the scale for a value such as `100e2147483647`. - * Zero is whole at any scale: AOSP libcore before Android 10 returns a zero such as `0.0` unchanged from + * Zero is whole at any scale: some older Android versions return a zero such as `0.0` unchanged from * [BigDecimal.stripTrailingZeros]. */ private fun isFractional(decimal: BigDecimal): Boolean { diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index bc9b9118e2..9e3c4ccde0 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -778,7 +778,8 @@ internal class DatadogFlagsClientTest { "float, 1e3, 1000", "number, 0e5, 0", "number, 10e-1, 1", - "number, 1.5e1, 15" + "number, 1.5e1, 15", + "number, 5.00000000000000000000000000000000000000000000000000000000000000, 5" ) fun `M return value W resolve() { numeric flag, whole value, Int default }`( fakeVariationType: String, @@ -899,6 +900,7 @@ internal class DatadogFlagsClientTest { "number, 1e-2147483648", "number, 1e2147483649", "number, 10000000000000000000000000000000000000000000000000000000000000000.0", + "number, 5.000000000000000000000000000000000000000000000000000000000000000", "float, NaN" ) fun `M return default with PARSE_ERROR W resolve() { malformed numeric value, Int default }`( From c90d1c3e7da9ffe8c4e85903dac74a0478ad1579 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 3 Nov 2026 20:55:05 -0500 Subject: [PATCH 4/8] FFL-3565: Test the plain-integer exemption from the length limit Add a 65-character zero-padded integer row so moving the length check before toIntOrNull() fails a test. Ref: FFL-3565 --- .../datadog/android/flags/internal/DatadogFlagsClientTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index 9e3c4ccde0..5c1212349d 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -779,7 +779,8 @@ internal class DatadogFlagsClientTest { "number, 0e5, 0", "number, 10e-1, 1", "number, 1.5e1, 15", - "number, 5.00000000000000000000000000000000000000000000000000000000000000, 5" + "number, 5.00000000000000000000000000000000000000000000000000000000000000, 5", + "number, 00000000000000000000000000000000000000000000000000000000000000005, 5" ) fun `M return value W resolve() { numeric flag, whole value, Int default }`( fakeVariationType: String, From 61f82932ac247e9ca98352c6c70e80ae60328ca1 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Wed, 4 Nov 2026 07:10:23 -0500 Subject: [PATCH 5/8] FFL-3565: Check whole values in Double reads at 2^53 and above Return TYPE_MISMATCH for a whole value at 2^53 or more that matches neither the Double nor its printed form. Return PARSE_ERROR for NaN, Infinity and text over 64 characters at that magnitude. Update docs. Ref: FFL-3565 --- features/dd-sdk-android-flags/README.md | 2 +- .../com/datadog/android/flags/FlagsClient.kt | 5 ++ .../flags/internal/FlagValueConverter.kt | 48 ++++++++++- .../flags/internal/DatadogFlagsClientTest.kt | 85 ++++++++++++++++++- 4 files changed, 136 insertions(+), 4 deletions(-) diff --git a/features/dd-sdk-android-flags/README.md b/features/dd-sdk-android-flags/README.md index f8fc201ca0..5beb2196ed 100644 --- a/features/dd-sdk-android-flags/README.md +++ b/features/dd-sdk-android-flags/README.md @@ -270,7 +270,7 @@ val maxRetries = client.resolveIntValue("max-retry-count", 3) val discountPercentage = client.resolveDoubleValue("discount-rate", 0.0) ``` -Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise it returns the default value: `resolve()` reports `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`, and `PARSE_ERROR` when the value can't be parsed. To bound parsing work, a value longer than 64 characters is not parsed unless it is a plain integer within `Int` range, so it reports `PARSE_ERROR`. +Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise it returns the default value: `resolve()` reports `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`, and `PARSE_ERROR` when the value can't be parsed. To bound parsing work, a value longer than 64 characters is not parsed unless it is a plain integer within `Int` range, so it reports `PARSE_ERROR`. `resolveDoubleValue` returns a fractional value as the nearest `Double`, such as `1.2`. It returns a whole value only when the `Double` has exactly that value, or prints as that value, such as `1e300`. Otherwise `resolve()` reports `TYPE_MISMATCH`, for example for `9007199254740993`. `NaN` and `Infinity` report `PARSE_ERROR`. So does a value longer than 64 characters whose `Double` is 2^53 or more in magnitude. **Structured flags** ```kotlin diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index a6302e2bff..3dc14db92f 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -130,6 +130,11 @@ interface FlagsClient { /** * Resolves a numeric flag value. * + * Any numeric flag type is accepted. A fractional value, such as `1.2`, resolves to the nearest [Double]. A + * whole value resolves only when the [Double] has exactly that value, or prints as that value, such as `1e300`. + * A value such as `9007199254740993` returns the default value. So does a value that can't be parsed, such as + * `NaN`, and a value longer than 64 characters whose [Double] is 2^53 or more in magnitude. + * * @param flagKey The unique identifier of the flag to resolve. * @param defaultValue The value to return if the flag cannot be retrieved or parsed. * @return The numeric value of the flag as a double, or the default value if unavailable. diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt index a075472018..9eefa4b90d 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt @@ -32,9 +32,13 @@ internal object FlagValueConverter { private val INT_MIN = BigDecimal(Int.MIN_VALUE) private val INT_MAX = BigDecimal(Int.MAX_VALUE) - // The longest decimal value that Int reads parse. This bounds the BigDecimal work on each read. + // The longest decimal value that Int reads, and Double reads at or above 2^53, parse with BigDecimal. + // This bounds the BigDecimal work on each read. private const val MAX_DECIMAL_LENGTH = 64 + // 2^53. Below this magnitude, Double holds every whole value exactly. + private const val EXACT_DOUBLE_LIMIT = 9_007_199_254_740_992.0 + /** * Converts a flag's string value to the expected type. * @@ -45,6 +49,7 @@ internal object FlagValueConverter { * @return Result containing the converted value on success, * or a Failure with TypeMismatchException if types are incompatible * or a numeric value can't be read as Int without loss, + * or a whole value matches neither the Double nor its printed form, * or a Failure with the parse exception if conversion failed */ @Suppress("UNCHECKED_CAST") @@ -62,7 +67,7 @@ internal object FlagValueConverter { Boolean::class -> variationValue.lowercase(Locale.US).toBooleanStrictOrNull() as? T String::class -> variationValue as T Int::class -> parseLosslessInt(variationValue, variationType) as? T - Double::class -> variationValue.toDoubleOrNull() as? T + Double::class -> parseLosslessDouble(variationValue, variationType) as? T Map::class -> variationValue.toMap() as? T JSONObject::class -> { @Suppress("UnsafeThirdPartyFunctionCall") // Safe: wrapped in runCatching @@ -149,6 +154,45 @@ internal object FlagValueConverter { return decimal.toInt() } + /** + * A whole value reads as Double only when the Double equals it, or when the Double prints as the same value, + * such as `1e300`. So `9007199254740993` is a type mismatch. A fractional value reads as the nearest Double, + * such as `1.2`. A value that can't be parsed returns null, which [convert] reports as a parse error. So does + * a value whose parsed Double is 2^53 or more in magnitude, when the value is longer than [MAX_DECIMAL_LENGTH] + * characters or [BigDecimal] can't parse it. + */ + private fun parseLosslessDouble(variationValue: String, variationType: String): Double? = + variationValue.toDoubleOrNull()?.let { double -> + if (double > -EXACT_DOUBLE_LIMIT && double < EXACT_DOUBLE_LIMIT) { + double + } else { + variationValue.takeIf { it.length <= MAX_DECIMAL_LENGTH } + ?.toBigDecimalOrNull() + ?.let { requireLosslessDouble(it, double, variationValue, variationType) } + } + } + + private fun requireLosslessDouble( + decimal: BigDecimal, + double: Double, + variationValue: String, + variationType: String + ): Double { + if (!isFractional(decimal) && !isExactDouble(decimal, double)) { + @Suppress("ThrowingInternalException") // Caught by runCatching in convert() + throw TypeMismatchException( + "Flag has type '$variationType' with whole value '$variationValue' that Double can't hold exactly " + + "but Double was requested" + ) + } + return double + } + + @Suppress("UnsafeThirdPartyFunctionCall") // Safe: BigDecimal(Double) and valueOf() only run for a finite Double + private fun isExactDouble(decimal: BigDecimal, double: Double): Boolean = + double != Double.POSITIVE_INFINITY && double != Double.NEGATIVE_INFINITY && + (decimal.compareTo(BigDecimal(double)) == 0 || decimal.compareTo(BigDecimal.valueOf(double)) == 0) + /** * A value with a scale of 0 or less is whole. Check the scale first: it also prevents * [BigDecimal.stripTrailingZeros] from overflowing the scale for a value such as `100e2147483647`. diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index 5c1212349d..e6ccacc271 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -1000,7 +1000,13 @@ internal class DatadogFlagsClientTest { "number, 1.5, 1.5", "integer, 7, 7.0", "integer, 5.0, 5.0", - "float, 5, 5.0" + "float, 5, 5.0", + "number, 9007199254740992, 9007199254740992.0", + "number, -9223372036854775808, -9223372036854775808.0", + "float, 18446744073709551616, 18446744073709551616.0", + "number, 1e300, 1e300", + "number, 1.20000000000000001, 1.2", + "float, 9007199254740993.5, 9007199254740994.0" ) fun `M return value W resolve() { numeric flag, Double default }`( fakeVariationType: String, @@ -1026,6 +1032,83 @@ internal class DatadogFlagsClientTest { assertThat(result.errorCode).isNull() } + @ParameterizedTest + @CsvSource( + "number, 9007199254740993", + "number, -9007199254740993", + "number, 9223372036854775807", + "number, 9007199254740993.0", + "number, 1e400", + "number, 1e2147483648", + "integer, 9007199254740993", + "float, 9007199254740993" + ) + fun `M return default with TYPE_MISMATCH W resolve() { whole value Double can't hold, Double default }`( + fakeVariationType: String, + fakeVariationValue: String, + forge: Forge + ) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.aDouble() + val fakeFlag = forge.getForgery().copy( + variationType = fakeVariationType, + variationValue = fakeVariationValue, + doLog = true + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + Pair(fakeFlag, fakeDefaultContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeDefaultValue) + assertThat(result.errorCode).isEqualTo(ErrorCode.TYPE_MISMATCH) + assertThat(result.errorMessage).isEqualTo( + "Flag '$fakeFlagKey': Flag has type '$fakeVariationType' with whole value " + + "'$fakeVariationValue' that Double can't hold exactly but Double was requested" + ) + verifyNoInteractions(mockProcessor) + } + + @ParameterizedTest + @CsvSource( + "number, not-a-number", + "number, NaN", + "number, Infinity", + "float, -Infinity", + "number, 10000000000000000000000000000000000000000000000000000000000000000.0", + "number, 9007199254740992.0000000000000000000000000000000000000000000000001", + "number, 1e2147483649" + ) + fun `M return default with PARSE_ERROR W resolve() { malformed numeric value, Double default }`( + fakeVariationType: String, + fakeVariationValue: String, + forge: Forge + ) { + // Given + val fakeFlagKey = forge.anAlphabeticalString() + val fakeDefaultValue = forge.aDouble() + val fakeFlag = forge.getForgery().copy( + variationType = fakeVariationType, + variationValue = fakeVariationValue, + doLog = true + ) + whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn + Pair(fakeFlag, fakeDefaultContext) + + // When + val result = testedClient.resolve(fakeFlagKey, fakeDefaultValue) + + // Then + assertThat(result.value).isEqualTo(fakeDefaultValue) + assertThat(result.errorCode).isEqualTo(ErrorCode.PARSE_ERROR) + assertThat(result.errorMessage) + .isEqualTo("Flag '$fakeFlagKey': Failed to parse value '$fakeVariationValue' as Double") + verifyNoInteractions(mockProcessor) + } + @ParameterizedTest @ValueSource(strings = ["boolean", "string", "object"]) fun `M return default with TYPE_MISMATCH W resolve() { non-numeric flag, Double default }`( From 689a921b01f6eaf9b9ae89fa18dd8ab3e19a2c55 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Wed, 4 Nov 2026 11:36:17 -0500 Subject: [PATCH 6/8] Apply batched suggestions from May's code review Co-authored-by: May Lee --- features/dd-sdk-android-flags/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/dd-sdk-android-flags/README.md b/features/dd-sdk-android-flags/README.md index 5beb2196ed..95b9b57d87 100644 --- a/features/dd-sdk-android-flags/README.md +++ b/features/dd-sdk-android-flags/README.md @@ -270,7 +270,7 @@ val maxRetries = client.resolveIntValue("max-retry-count", 3) val discountPercentage = client.resolveDoubleValue("discount-rate", 0.0) ``` -Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise it returns the default value: `resolve()` reports `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`, and `PARSE_ERROR` when the value can't be parsed. To bound parsing work, a value longer than 64 characters is not parsed unless it is a plain integer within `Int` range, so it reports `PARSE_ERROR`. `resolveDoubleValue` returns a fractional value as the nearest `Double`, such as `1.2`. It returns a whole value only when the `Double` has exactly that value, or prints as that value, such as `1e300`. Otherwise `resolve()` reports `TYPE_MISMATCH`, for example for `9007199254740993`. `NaN` and `Infinity` report `PARSE_ERROR`. So does a value longer than 64 characters whose `Double` is 2^53 or more in magnitude. +Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise, it returns the default value: `resolve()`, reports `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`, and `PARSE_ERROR` when the value can't be parsed. To bound parsing work, a value longer than 64 characters is not parsed unless it is a plain integer within `Int` range, so it reports `PARSE_ERROR`. `resolveDoubleValue` returns a fractional value as the nearest `Double`, such as `1.2`. It returns a whole value only when the `Double` has exactly that value, or prints as that value, such as `1e300`. Otherwise, `resolve()` reports `TYPE_MISMATCH`, such as for `9007199254740993`. `NaN` and `Infinity` report `PARSE_ERROR`. The same applies to a value longer than 64 characters whose `Double` is 2^53 or more in magnitude. **Structured flags** ```kotlin @@ -329,7 +329,7 @@ if (result.errorCode != null) { ##### Error codes - `FLAG_NOT_FOUND` - The flag could not be found - `PARSE_ERROR` - Error parsing the flag value -- `TYPE_MISMATCH` - The type of the flag value doesn't match the expected type, such as a fractional value read with `resolveIntValue` +- `TYPE_MISMATCH` - The flag value's type doesn't match the expected type, such as a fractional value read with `resolveIntValue` - `TARGETING_KEY_MISSING` - No targeting key was provided - `INVALID_CONTEXT` - The evaluation context is invalid - `PROVIDER_NOT_READY` - The provider is not yet ready From 7e5ab288bc0301fd06e6050970640d1df12d5873 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Wed, 4 Nov 2026 14:12:21 -0500 Subject: [PATCH 7/8] FFL-3565: Mark throwing converter helpers and fix README wording Add @Throws to the lossless Int and Double helpers, note why Int reads do not use intValueExact(), and fix two README slips from the applied suggestions. Ref: FFL-3565 --- features/dd-sdk-android-flags/README.md | 2 +- .../datadog/android/flags/internal/FlagValueConverter.kt | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/README.md b/features/dd-sdk-android-flags/README.md index 95b9b57d87..fbef56784e 100644 --- a/features/dd-sdk-android-flags/README.md +++ b/features/dd-sdk-android-flags/README.md @@ -270,7 +270,7 @@ val maxRetries = client.resolveIntValue("max-retry-count", 3) val discountPercentage = client.resolveDoubleValue("discount-rate", 0.0) ``` -Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise, it returns the default value: `resolve()`, reports `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`, and `PARSE_ERROR` when the value can't be parsed. To bound parsing work, a value longer than 64 characters is not parsed unless it is a plain integer within `Int` range, so it reports `PARSE_ERROR`. `resolveDoubleValue` returns a fractional value as the nearest `Double`, such as `1.2`. It returns a whole value only when the `Double` has exactly that value, or prints as that value, such as `1e300`. Otherwise, `resolve()` reports `TYPE_MISMATCH`, such as for `9007199254740993`. `NaN` and `Infinity` report `PARSE_ERROR`. The same applies to a value longer than 64 characters whose `Double` is 2^53 or more in magnitude. +Any numeric flag can be read with either method. `resolveIntValue` returns the value only when it is a whole number within `Int` range, such as `5` or `5.0`. Otherwise, it returns the default value: `resolve()` reports `TYPE_MISMATCH` when the value is fractional, such as `1.2`, or outside `Int` range, such as `2147483648`, and `PARSE_ERROR` when the value can't be parsed. To bound parsing work, a value longer than 64 characters is not parsed unless it is a plain integer within `Int` range, so it reports `PARSE_ERROR`. `resolveDoubleValue` returns a fractional value as the nearest `Double`, such as `1.2`. It returns a whole value only when the `Double` has exactly that value, or prints as that value, such as `1e300`. For other whole values, such as `9007199254740993`, `resolve()` reports `TYPE_MISMATCH`. `NaN` and `Infinity` report `PARSE_ERROR`. The same applies to a value longer than 64 characters whose `Double` is 2^53 or more in magnitude. **Structured flags** ```kotlin diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt index 9eefa4b90d..b69bb48e13 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt @@ -136,11 +136,15 @@ internal object FlagValueConverter { * which [convert] reports as a parse error. So does a value that is not a plain Int and is longer than * [MAX_DECIMAL_LENGTH] characters. */ + @Throws(TypeMismatchException::class) private fun parseLosslessInt(variationValue: String, variationType: String): Int? = variationValue.toIntOrNull() ?: variationValue.takeIf { it.length <= MAX_DECIMAL_LENGTH } ?.toBigDecimalOrNull() ?.let { requireLosslessInt(it, variationValue, variationType) } + // Compare with the Int bounds instead of calling BigDecimal.intValueExact(): on Android 9, intValueExact() + // expands the power of ten, so a short value such as `1e10000000` takes seconds. + @Throws(TypeMismatchException::class) private fun requireLosslessInt(decimal: BigDecimal, variationValue: String, variationType: String): Int { val problem = when { isFractional(decimal) -> "fractional value '$variationValue'" @@ -161,6 +165,7 @@ internal object FlagValueConverter { * a value whose parsed Double is 2^53 or more in magnitude, when the value is longer than [MAX_DECIMAL_LENGTH] * characters or [BigDecimal] can't parse it. */ + @Throws(TypeMismatchException::class) private fun parseLosslessDouble(variationValue: String, variationType: String): Double? = variationValue.toDoubleOrNull()?.let { double -> if (double > -EXACT_DOUBLE_LIMIT && double < EXACT_DOUBLE_LIMIT) { @@ -172,6 +177,7 @@ internal object FlagValueConverter { } } + @Throws(TypeMismatchException::class) private fun requireLosslessDouble( decimal: BigDecimal, double: Double, From 8018347f6bee9acb597190a47dbb0c764152fa3e Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Thu, 5 Nov 2026 13:45:45 -0500 Subject: [PATCH 8/8] FFL-3565: Test Double reads at the length limit and below 2^53 Add two Double success rows: a 64-character value at 2^53, and a 65-character value below 2^53, which skips the length check. Ref: FFL-3565 --- .../datadog/android/flags/internal/DatadogFlagsClientTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index e6ccacc271..607ead6325 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -1006,7 +1006,9 @@ internal class DatadogFlagsClientTest { "float, 18446744073709551616, 18446744073709551616.0", "number, 1e300, 1e300", "number, 1.20000000000000001, 1.2", - "float, 9007199254740993.5, 9007199254740994.0" + "float, 9007199254740993.5, 9007199254740994.0", + "number, 9007199254740992.00000000000000000000000000000000000000000000000, 9007199254740992.0", + "number, 1.000000000000000000000000000000000000000000000000000000000000000, 1.0" ) fun `M return value W resolve() { numeric flag, Double default }`( fakeVariationType: String,