From b725831ec94a9e16c43ceb6e0ce34f8fdc41960a Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 25 May 2026 16:56:46 -0400 Subject: [PATCH 1/7] feat(flagd-core): add disabled flag evaluation e2e BDD scenarios Signed-off-by: Jonathan Norris --- .../tests/e2e/features/disabled.feature | 21 +++++++ .../tests/e2e/test_disabled.py | 56 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tools/openfeature-flagd-core/tests/e2e/features/disabled.feature create mode 100644 tools/openfeature-flagd-core/tests/e2e/test_disabled.py diff --git a/tools/openfeature-flagd-core/tests/e2e/features/disabled.feature b/tools/openfeature-flagd-core/tests/e2e/features/disabled.feature new file mode 100644 index 00000000..90968d20 --- /dev/null +++ b/tools/openfeature-flagd-core/tests/e2e/features/disabled.feature @@ -0,0 +1,21 @@ +@disabled +Feature: Disabled flag evaluation + + # A flag with state=DISABLED resolves with reason=DISABLED and no value or + # variant; the SDK returns the caller's code default. + # Relates to: https://github.com/open-feature/flagd/issues/1965 + + Scenario Outline: Evaluating a disabled flag returns reason DISABLED and code default + Given an evaluator + And a -flag with key "" and a fallback value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "DISABLED" + + Examples: + | key | type | default | + | disabled-boolean-flag | Boolean | false | + | disabled-string-flag | String | bye | + | disabled-integer-flag | Integer | 1 | + | disabled-float-flag | Float | 0.1 | + | disabled-object-flag | Object | {} | diff --git a/tools/openfeature-flagd-core/tests/e2e/test_disabled.py b/tools/openfeature-flagd-core/tests/e2e/test_disabled.py new file mode 100644 index 00000000..04fc330d --- /dev/null +++ b/tools/openfeature-flagd-core/tests/e2e/test_disabled.py @@ -0,0 +1,56 @@ +import json +from pathlib import Path + +import pytest +from pytest_bdd import scenarios + +from openfeature.contrib.tools.flagd.core import FlagdCore +from openfeature.contrib.tools.flagd.testkit.steps import * # noqa: F403 + +scenarios(str(Path(__file__).parent / "features")) + +_DISABLED_FLAGS = json.dumps( + { + "flags": { + "disabled-boolean-flag": { + "state": "DISABLED", + "variants": {"on": True, "off": False}, + "defaultVariant": "on", + }, + "disabled-string-flag": { + "state": "DISABLED", + "variants": {"greeting": "hi", "parting": "bye"}, + "defaultVariant": "greeting", + }, + "disabled-integer-flag": { + "state": "DISABLED", + "variants": {"one": 1, "ten": 10}, + "defaultVariant": "ten", + }, + "disabled-float-flag": { + "state": "DISABLED", + "variants": {"tenth": 0.1, "half": 0.5}, + "defaultVariant": "half", + }, + "disabled-object-flag": { + "state": "DISABLED", + "variants": { + "empty": {}, + "template": { + "showImages": True, + "title": "Check out these pics!", + "imagesPerPage": 100, + }, + }, + "defaultVariant": "template", + }, + } + } +) + + +@pytest.fixture +def evaluator(): + core = FlagdCore() + core.set_flags(_DISABLED_FLAGS) + return core From 6acf7e9bfc09c8d4ce319ffc419e51096680e783 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 1 Jun 2026 14:45:51 -0400 Subject: [PATCH 2/7] chore: bump flagd-testbed submodule to v3.8.0 Signed-off-by: Todd Baert --- providers/openfeature-provider-flagd/openfeature/test-harness | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/openfeature-provider-flagd/openfeature/test-harness b/providers/openfeature-provider-flagd/openfeature/test-harness index b507289c..7575a1dc 160000 --- a/providers/openfeature-provider-flagd/openfeature/test-harness +++ b/providers/openfeature-provider-flagd/openfeature/test-harness @@ -1 +1 @@ -Subproject commit b507289c45fca9c2d312c7231929e5b95eae62bb +Subproject commit 7575a1dc45f176e57e809748a712a555e9aa5d11 From f1a5d1d2b560611292342133df163b04a0eda044 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 1 Jun 2026 14:45:51 -0400 Subject: [PATCH 3/7] feat(flagd): RPC resolver substitutes caller default on reason=DISABLED Mirrors the existing reason=DEFAULT substitution: when the server returns an empty variant alongside DISABLED, surface the caller's code default value rather than the zero proto value. Signed-off-by: Todd Baert --- .../src/openfeature/contrib/provider/flagd/resolvers/grpc.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py index 2e2db27a..599d151e 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py @@ -443,8 +443,9 @@ def _resolve( # noqa: PLR0915 C901 raise GeneralError(message) from e # When no default variant is configured, the server returns an empty/zero proto - # value with reason=DEFAULT. In that case, return the caller's code default value. - if response.reason == Reason.DEFAULT and not response.variant: + # value with reason=DEFAULT. For DISABLED flags the server omits the variant too. + # In both cases, return the caller's code default value. + if response.reason in (Reason.DEFAULT, Reason.DISABLED) and not response.variant: value = default_value # Got a valid flag and valid type. Return it. From 91ee54e8d6b30b9262d7cdfd1b8365dbebf35598 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 1 Jun 2026 14:48:16 -0400 Subject: [PATCH 4/7] fix(flagd): RPC Object resolver tolerates missing value field MessageToDict drops the value field entirely for DISABLED responses (no value is sent). Fall back to the caller's default_value via .get() instead of raising KeyError; the substitution branch then surfaces the default with reason=DISABLED. Signed-off-by: Todd Baert --- .../openfeature/contrib/provider/flagd/resolvers/grpc.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py index 599d151e..8fdf96e7 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py @@ -409,9 +409,11 @@ def _resolve( # noqa: PLR0915 C901 flag_key=flag_key, context=context ) response = self.stub.ResolveObject(request, **call_args) - value = MessageToDict(response, preserving_proto_field_name=True)[ - "value" - ] + # DISABLED responses omit the value field entirely; fall back to default_value + # here so the substitution below (or the caller) gets a sane mapping. + value = MessageToDict(response, preserving_proto_field_name=True).get( + "value", default_value + ) elif flag_type == FlagType.FLOAT: request = evaluation_pb2.ResolveFloatRequest( flag_key=flag_key, context=context From e439b30fec3cf4db498dcc75913282c2c4c6e725 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 1 Jun 2026 15:08:02 -0400 Subject: [PATCH 5/7] style(flagd): apply ruff format to DISABLED guard Signed-off-by: Todd Baert --- .../src/openfeature/contrib/provider/flagd/resolvers/grpc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py index 8fdf96e7..5cef250a 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py @@ -447,7 +447,10 @@ def _resolve( # noqa: PLR0915 C901 # When no default variant is configured, the server returns an empty/zero proto # value with reason=DEFAULT. For DISABLED flags the server omits the variant too. # In both cases, return the caller's code default value. - if response.reason in (Reason.DEFAULT, Reason.DISABLED) and not response.variant: + if ( + response.reason in (Reason.DEFAULT, Reason.DISABLED) + and not response.variant + ): value = default_value # Got a valid flag and valid type. Return it. From 7b75ac8cf956108bd17d16e145c7cc9e03de11ff Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 1 Jun 2026 15:16:28 -0400 Subject: [PATCH 6/7] chore(flagd-core): drop redundant disabled.feature and test_disabled.py v3.8.0 of the test-harness submodule already bundles disabled.feature in evaluator/gherkin/ and the disabled-* flags in evaluator/flags/testkit-flags.json. The hatch build hook auto-copies both into the testkit, so test_evaluator.py picks the disabled scenarios up via get_features_path() without any local files. Signed-off-by: Todd Baert --- .../tests/e2e/features/disabled.feature | 21 ------- .../tests/e2e/test_disabled.py | 56 ------------------- 2 files changed, 77 deletions(-) delete mode 100644 tools/openfeature-flagd-core/tests/e2e/features/disabled.feature delete mode 100644 tools/openfeature-flagd-core/tests/e2e/test_disabled.py diff --git a/tools/openfeature-flagd-core/tests/e2e/features/disabled.feature b/tools/openfeature-flagd-core/tests/e2e/features/disabled.feature deleted file mode 100644 index 90968d20..00000000 --- a/tools/openfeature-flagd-core/tests/e2e/features/disabled.feature +++ /dev/null @@ -1,21 +0,0 @@ -@disabled -Feature: Disabled flag evaluation - - # A flag with state=DISABLED resolves with reason=DISABLED and no value or - # variant; the SDK returns the caller's code default. - # Relates to: https://github.com/open-feature/flagd/issues/1965 - - Scenario Outline: Evaluating a disabled flag returns reason DISABLED and code default - Given an evaluator - And a -flag with key "" and a fallback value "" - When the flag was evaluated with details - Then the resolved details value should be "" - And the reason should be "DISABLED" - - Examples: - | key | type | default | - | disabled-boolean-flag | Boolean | false | - | disabled-string-flag | String | bye | - | disabled-integer-flag | Integer | 1 | - | disabled-float-flag | Float | 0.1 | - | disabled-object-flag | Object | {} | diff --git a/tools/openfeature-flagd-core/tests/e2e/test_disabled.py b/tools/openfeature-flagd-core/tests/e2e/test_disabled.py deleted file mode 100644 index 04fc330d..00000000 --- a/tools/openfeature-flagd-core/tests/e2e/test_disabled.py +++ /dev/null @@ -1,56 +0,0 @@ -import json -from pathlib import Path - -import pytest -from pytest_bdd import scenarios - -from openfeature.contrib.tools.flagd.core import FlagdCore -from openfeature.contrib.tools.flagd.testkit.steps import * # noqa: F403 - -scenarios(str(Path(__file__).parent / "features")) - -_DISABLED_FLAGS = json.dumps( - { - "flags": { - "disabled-boolean-flag": { - "state": "DISABLED", - "variants": {"on": True, "off": False}, - "defaultVariant": "on", - }, - "disabled-string-flag": { - "state": "DISABLED", - "variants": {"greeting": "hi", "parting": "bye"}, - "defaultVariant": "greeting", - }, - "disabled-integer-flag": { - "state": "DISABLED", - "variants": {"one": 1, "ten": 10}, - "defaultVariant": "ten", - }, - "disabled-float-flag": { - "state": "DISABLED", - "variants": {"tenth": 0.1, "half": 0.5}, - "defaultVariant": "half", - }, - "disabled-object-flag": { - "state": "DISABLED", - "variants": { - "empty": {}, - "template": { - "showImages": True, - "title": "Check out these pics!", - "imagesPerPage": 100, - }, - }, - "defaultVariant": "template", - }, - } - } -) - - -@pytest.fixture -def evaluator(): - core = FlagdCore() - core.set_flags(_DISABLED_FLAGS) - return core From ae05caca955e45b3b0abe46ee2e4640d38f99714 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 1 Jun 2026 15:17:35 -0400 Subject: [PATCH 7/7] Apply suggestion from @toddbaert Signed-off-by: Todd Baert --- .../src/openfeature/contrib/provider/flagd/resolvers/grpc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py index 5cef250a..dcec4adf 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py @@ -410,7 +410,6 @@ def _resolve( # noqa: PLR0915 C901 ) response = self.stub.ResolveObject(request, **call_args) # DISABLED responses omit the value field entirely; fall back to default_value - # here so the substitution below (or the caller) gets a sane mapping. value = MessageToDict(response, preserving_proto_field_name=True).get( "value", default_value )