Skip to content

Commit e530620

Browse files
committed
fix: reject boolean values for integer flags
Exclude bool from integer flag type checks while preserving other integer subclasses. Return the caller default when the async client detects a type mismatch, matching the sync path. Cover sync and async value/details getters, exact fallback types, hook behavior, and valid flag values with regression tests for #619.
1 parent 371aca1 commit e530620

2 files changed

Lines changed: 122 additions & 2 deletions

File tree

openfeature/client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,7 @@ async def _create_provider_evaluation_async(
890890
if err := _typecheck_flag_value(value=resolution.value, flag_type=flag_type):
891891
return FlagEvaluationDetails(
892892
flag_key=flag_key,
893-
value=resolution.value,
893+
value=default_value,
894894
reason=Reason.ERROR,
895895
error_code=err.error_code,
896896
error_message=err.error_message,
@@ -1001,6 +1001,9 @@ def _typecheck_flag_value(
10011001
py_type = type_map.get(flag_type)
10021002
if not py_type:
10031003
return GeneralError(error_message="Unknown flag type")
1004-
if not isinstance(value, py_type):
1004+
# bool is an int subclass in Python, but not an integer flag value.
1005+
if not isinstance(value, py_type) or (
1006+
flag_type == FlagType.INTEGER and isinstance(value, bool)
1007+
):
10051008
return TypeMismatchError(f"Expected type {py_type} but got {type(value)}")
10061009
return None

tests/test_client.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,123 @@ async def test_typecheck_flag_value_general_error():
434434
assert err.error_message == "Unknown flag type"
435435

436436

437+
@pytest.mark.parametrize(
438+
"flag_type, flag_value, default_value",
439+
[
440+
("integer", True, 1),
441+
("integer", False, 0),
442+
("float", True, 1.0),
443+
("float", False, 0.0),
444+
("boolean", 1, False),
445+
("integer", "1", 0),
446+
("float", 1, 0.0),
447+
("string", True, "fallback"),
448+
("object", True, {}),
449+
],
450+
)
451+
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
452+
@pytest.mark.asyncio
453+
async def test_client_returns_default_on_type_mismatch(
454+
flag_type, flag_value, default_value, is_async
455+
):
456+
provider = InMemoryProvider(
457+
{"flag": InMemoryFlag("enabled", {"enabled": flag_value})}
458+
)
459+
api.set_provider_and_wait(provider)
460+
client = get_client()
461+
spy_hook = MagicMock(spec=Hook)
462+
client.add_hooks([spy_hook])
463+
suffix = "_async" if is_async else ""
464+
465+
get_details = getattr(client, f"get_{flag_type}_details{suffix}")
466+
details = get_details("flag", default_value)
467+
if is_async:
468+
details = await details
469+
470+
assert details.flag_key == "flag"
471+
assert details.value == default_value
472+
# Equality alone cannot distinguish True from 1 (or False from 0).
473+
assert type(details.value) is type(default_value)
474+
assert details.reason == Reason.ERROR
475+
assert details.error_code == ErrorCode.TYPE_MISMATCH
476+
expected_type = (dict, list) if flag_type == "object" else type(default_value)
477+
assert (
478+
details.error_message
479+
== f"Expected type {expected_type} but got {type(flag_value)}"
480+
)
481+
spy_hook.error.assert_called_once()
482+
assert (
483+
spy_hook.error.call_args.kwargs["exception"].error_code
484+
== ErrorCode.TYPE_MISMATCH
485+
)
486+
spy_hook.after.assert_not_called()
487+
spy_hook.finally_after.assert_called_once()
488+
assert spy_hook.finally_after.call_args.kwargs["details"].value == default_value
489+
assert type(spy_hook.finally_after.call_args.kwargs["details"].value) is type(
490+
default_value
491+
)
492+
493+
get_value = getattr(client, f"get_{flag_type}_value{suffix}")
494+
value = get_value("flag", default_value)
495+
if is_async:
496+
value = await value
497+
assert value == default_value
498+
assert type(value) is type(default_value)
499+
500+
501+
@pytest.mark.parametrize(
502+
"flag_type, flag_value, default_value",
503+
[
504+
("boolean", True, False),
505+
("boolean", False, True),
506+
("integer", 0, -1),
507+
("integer", 1, -1),
508+
("integer", -1, 0),
509+
("float", 0.0, -1.0),
510+
("float", 1.5, -1.0),
511+
("string", "enabled", "default"),
512+
("object", {"enabled": True}, {}),
513+
("object", [True, 1, "enabled"], []),
514+
],
515+
)
516+
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
517+
@pytest.mark.asyncio
518+
async def test_client_preserves_matching_flag_types(
519+
flag_type, flag_value, default_value, is_async
520+
):
521+
provider = InMemoryProvider(
522+
{"flag": InMemoryFlag("enabled", {"enabled": flag_value})}
523+
)
524+
api.set_provider_and_wait(provider)
525+
client = get_client()
526+
suffix = "_async" if is_async else ""
527+
528+
get_details = getattr(client, f"get_{flag_type}_details{suffix}")
529+
details = get_details("flag", default_value)
530+
if is_async:
531+
details = await details
532+
533+
assert details.value == flag_value
534+
assert type(details.value) is type(flag_value)
535+
assert details.variant == "enabled"
536+
assert details.reason == Reason.STATIC
537+
assert details.error_code is None
538+
539+
get_value = getattr(client, f"get_{flag_type}_value{suffix}")
540+
value = get_value("flag", default_value)
541+
if is_async:
542+
value = await value
543+
assert value == flag_value
544+
assert type(value) is type(flag_value)
545+
546+
547+
def test_typecheck_flag_value_accepts_integer_subclasses():
548+
class IntegerValue(int):
549+
pass
550+
551+
assert _typecheck_flag_value(IntegerValue(1), FlagType.INTEGER) is None
552+
553+
437554
@pytest.mark.asyncio
438555
async def test_typecheck_flag_value_type_mismatch_error():
439556
# Given

0 commit comments

Comments
 (0)