From 2c855ffa6138ac01688b90a25d4e5f3e326515db Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 9 Mar 2026 08:47:58 +0100 Subject: [PATCH 1/2] fix: handle integer flag values serialized as floats in JSON JSON doesn't distinguish between int and float numbers, so backends may serialize integer values as 400.0. The SDK now coerces whole-number floats to int when resolving integer flags, and accepts int values when resolving float flags. Co-Authored-By: Claude Opus 4.6 --- confidence/confidence.py | 10 ++- tests/test_confidence.py | 145 +++++++++++++++++++++++++++++++++++++++ tests/test_provider.py | 32 +++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) diff --git a/confidence/confidence.py b/confidence/confidence.py index e8ca087..2322339 100644 --- a/confidence/confidence.py +++ b/confidence/confidence.py @@ -53,7 +53,12 @@ def primitive_matches(value: FieldType, value_type: Type[Primitive]) -> bool: return ( value_type is None or (value_type is int and isinstance(value, int)) - or (value_type is float and isinstance(value, float)) + or ( + value_type is int + and isinstance(value, float) + and value == int(value) + ) + or (value_type is float and isinstance(value, (float, int))) or (value_type is str and isinstance(value, str)) or (value_type is bool and isinstance(value, bool)) ) @@ -562,6 +567,9 @@ def _select( ) raise TypeMismatchError("type of value did not match excepted type") + if value_type is int and isinstance(value, float): + value = int(value) + return value @staticmethod diff --git a/tests/test_confidence.py b/tests/test_confidence.py index 83e60c6..b1e9ddc 100644 --- a/tests/test_confidence.py +++ b/tests/test_confidence.py @@ -431,6 +431,119 @@ async def test_resolve_with_default_timeout_async(self): _, kwargs = mock_post.call_args self.assertEqual(kwargs["timeout"], DEFAULT_TIMEOUT_MS / 1000.0) + def test_resolve_integer_from_float_value(self): + """Test that an integer flag value serialized as 400.0 (float in JSON) + with intSchema can be correctly resolved as an integer.""" + with requests_mock.Mocker() as mock: + mock.post( + "https://resolver.confidence.dev/v1/flags:resolve", + json=INTEGER_AS_FLOAT_FLAG_RESOLVE, + ) + result = self.confidence.resolve_integer_details( + flag_key="test-flag.myinteger", + default_value=-1, + ) + + self.assertEqual(result.reason, Reason.TARGETING_MATCH) + self.assertEqual(result.flag_metadata["flag_key"], "test-flag.myinteger") + self.assertEqual(result.value, 400) + self.assertIsInstance(result.value, int) + + async def test_resolve_integer_from_float_value_async(self): + mock_response = httpx.Response( + status_code=200, + json=INTEGER_AS_FLOAT_FLAG_RESOLVE, + request=httpx.Request( + "POST", "https://resolver.confidence.dev/v1/flags:resolve" + ), + ) + + with patch("httpx.AsyncClient.post", return_value=mock_response): + result = await self.confidence.resolve_integer_details_async( + flag_key="test-flag.myinteger", default_value=-1 + ) + + self.assertEqual(result.reason, Reason.TARGETING_MATCH) + self.assertEqual(result.flag_metadata["flag_key"], "test-flag.myinteger") + self.assertEqual(result.value, 400) + self.assertIsInstance(result.value, int) + + def test_resolve_integer_from_negative_float_value(self): + with requests_mock.Mocker() as mock: + mock.post( + "https://resolver.confidence.dev/v1/flags:resolve", + json=_make_int_flag_resolve(-5.0), + ) + result = self.confidence.resolve_integer_details( + flag_key="test-flag.myinteger", + default_value=-1, + ) + + self.assertEqual(result.reason, Reason.TARGETING_MATCH) + self.assertEqual(result.value, -5) + self.assertIsInstance(result.value, int) + + def test_resolve_integer_from_zero_float_value(self): + with requests_mock.Mocker() as mock: + mock.post( + "https://resolver.confidence.dev/v1/flags:resolve", + json=_make_int_flag_resolve(0.0), + ) + result = self.confidence.resolve_integer_details( + flag_key="test-flag.myinteger", + default_value=-1, + ) + + self.assertEqual(result.reason, Reason.TARGETING_MATCH) + self.assertEqual(result.value, 0) + self.assertIsInstance(result.value, int) + + def test_resolve_integer_rejects_non_whole_float(self): + with requests_mock.Mocker() as mock: + mock.post( + "https://resolver.confidence.dev/v1/flags:resolve", + json=_make_int_flag_resolve(400.5), + ) + result = self.confidence.resolve_integer_details( + flag_key="test-flag.myinteger", + default_value=-1, + ) + + self.assertEqual(result.reason, Reason.ERROR) + self.assertEqual(result.value, -1) + self.assertEqual(result.error_code, ErrorCode.GENERAL) + + def test_resolve_float_from_integer_value(self): + with requests_mock.Mocker() as mock: + mock.post( + "https://resolver.confidence.dev/v1/flags:resolve", + json=FLOAT_AS_INTEGER_FLAG_RESOLVE, + ) + result = self.confidence.resolve_float_details( + flag_key="test-flag.mydouble", + default_value=-1.0, + ) + + self.assertEqual(result.reason, Reason.TARGETING_MATCH) + self.assertEqual(result.value, 42) + + async def test_resolve_float_from_integer_value_async(self): + mock_response = httpx.Response( + status_code=200, + json=FLOAT_AS_INTEGER_FLAG_RESOLVE, + request=httpx.Request( + "POST", "https://resolver.confidence.dev/v1/flags:resolve" + ), + ) + + with patch("httpx.AsyncClient.post", return_value=mock_response): + result = await self.confidence.resolve_float_details_async( + flag_key="test-flag.mydouble", default_value=-1.0 + ) + + self.assertEqual(result.reason, Reason.TARGETING_MATCH) + self.assertEqual(result.value, 42) + def test_handle_actual_timeout(self): with patch("requests.post") as mock_post: # Simulate a timeout by raising the Timeout exception @@ -514,6 +627,38 @@ async def test_handle_actual_timeout_async(self): }""" ) +def _make_int_flag_resolve(value): + return { + "resolvedFlags": [ + { + "flag": "flags/test-flag", + "variant": "flags/test-flag/variants/variant-1", + "value": {"myinteger": value}, + "flagSchema": {"schema": {"myinteger": {"intSchema": {}}}}, + "reason": "RESOLVE_REASON_MATCH", + "shouldApply": True, + } + ], + "resolveToken": "token1", + } + + +INTEGER_AS_FLOAT_FLAG_RESOLVE = _make_int_flag_resolve(400.0) + +FLOAT_AS_INTEGER_FLAG_RESOLVE = { + "resolvedFlags": [ + { + "flag": "flags/test-flag", + "variant": "flags/test-flag/variants/variant-1", + "value": {"mydouble": 42}, + "flagSchema": {"schema": {"mydouble": {"doubleSchema": {}}}}, + "reason": "RESOLVE_REASON_MATCH", + "shouldApply": True, + } + ], + "resolveToken": "token1", +} + SUCCESSFUL_FLAG_RESOLVE = json.loads( """{ "resolvedFlags": [ diff --git a/tests/test_provider.py b/tests/test_provider.py index 45323ea..211737b 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -243,6 +243,24 @@ def test_resolve_without_targeting_key(self): last_request.json()["evaluationContext"].get("connection"), "wifi" ) + def test_resolve_integer_from_float_value(self): + ctx = EvaluationContext(targeting_key="boop") + with requests_mock.Mocker() as mock: + mock.post( + "https://resolver.confidence.dev/v1/flags:resolve", + json=INTEGER_AS_FLOAT_FLAG_RESOLVE, + ) + result = self.provider.resolve_integer_details( + flag_key="test-flag.myinteger", + default_value=-1, + evaluation_context=ctx, + ) + + self.assertEqual(result.reason, Reason.TARGETING_MATCH) + self.assertEqual(result.flag_metadata["flag_key"], "test-flag.myinteger") + self.assertEqual(result.value, 400) + self.assertIsInstance(result.value, int) + def test_no_segment_match(self): ctx = EvaluationContext(attributes={"connection": "wifi"}) with requests_mock.Mocker() as mock: @@ -296,6 +314,20 @@ def test_no_segment_match(self): }""" ) +INTEGER_AS_FLOAT_FLAG_RESOLVE = { + "resolvedFlags": [ + { + "flag": "flags/test-flag", + "variant": "flags/test-flag/variants/variant-1", + "value": {"myinteger": 400.0}, + "flagSchema": {"schema": {"myinteger": {"intSchema": {}}}}, + "reason": "RESOLVE_REASON_MATCH", + "shouldApply": True, + } + ], + "resolveToken": "token1", +} + SUCCESSFUL_FLAG_RESOLVE = json.loads( """{ "resolvedFlags": [ From 279d32947a558075e96934358d3e4d287a1d1d17 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 9 Mar 2026 09:04:25 +0100 Subject: [PATCH 2/2] style: format with black Co-Authored-By: Claude Opus 4.6 --- confidence/confidence.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/confidence/confidence.py b/confidence/confidence.py index 2322339..d5c0723 100644 --- a/confidence/confidence.py +++ b/confidence/confidence.py @@ -53,11 +53,7 @@ def primitive_matches(value: FieldType, value_type: Type[Primitive]) -> bool: return ( value_type is None or (value_type is int and isinstance(value, int)) - or ( - value_type is int - and isinstance(value, float) - and value == int(value) - ) + or (value_type is int and isinstance(value, float) and value == int(value)) or (value_type is float and isinstance(value, (float, int))) or (value_type is str and isinstance(value, str)) or (value_type is bool and isinstance(value, bool))