Skip to content

Commit 64d755b

Browse files
fix(flagd): adjust flagd to updated error codes (#285)
Fixing the inconsistencies regarding error codes for edge cases, as described in #1679 Signed-off-by: Konvalinka <[email protected]> Co-authored-by: Simon Schrottner <[email protected]>
1 parent abd39e4 commit 64d755b

File tree

12 files changed

+140
-40
lines changed

12 files changed

+140
-40
lines changed

.gitmodules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
[submodule "providers/openfeature-provider-flagd/openfeature/test-harness"]
99
path = providers/openfeature-provider-flagd/openfeature/test-harness
1010
url = https://github.com/open-feature/flagd-testbed.git
11-
branch = v2.8.0
11+
branch = v2.10.2

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
)
66
from openfeature.evaluation_context import EvaluationContext
77
from openfeature.event import ProviderEventDetails
8-
from openfeature.exception import FlagNotFoundError, ParseError
8+
from openfeature.exception import ErrorCode, FlagNotFoundError, GeneralError, ParseError
99
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
1010

1111
from ..config import Config
1212
from .process.connector import FlagStateConnector
1313
from .process.connector.grpc_watcher import GrpcWatcher
14-
from .process.flags import FlagStore
14+
from .process.flags import Flag, FlagStore
1515
from .process.targeting import targeting
1616

1717
T = typing.TypeVar("T")
@@ -128,30 +128,55 @@ def _resolve(
128128
)
129129

130130
if not flag.targeting:
131-
variant, value = flag.default
132-
return FlagResolutionDetails(
133-
value, variant=variant, flag_metadata=metadata, reason=Reason.STATIC
134-
)
131+
return _default_resolve(flag, metadata, Reason.STATIC)
135132

136-
variant = targeting(flag.key, flag.targeting, evaluation_context)
133+
try:
134+
variant = targeting(flag.key, flag.targeting, evaluation_context)
135+
if variant is None:
136+
return _default_resolve(flag, metadata, Reason.DEFAULT)
137137

138-
if variant is None:
139-
variant, value = flag.default
140-
return FlagResolutionDetails(
141-
value, variant=variant, flag_metadata=metadata, reason=Reason.DEFAULT
142-
)
143-
if not isinstance(variant, (str, bool)):
144-
raise ParseError(
145-
"Parsed JSONLogic targeting did not return a string or bool"
146-
)
138+
# convert to string to support shorthand (boolean in python is with capital T hence the special case)
139+
if isinstance(variant, bool):
140+
variant = str(variant).lower()
141+
elif not isinstance(variant, str):
142+
variant = str(variant)
143+
144+
if variant not in flag.variants:
145+
raise GeneralError(
146+
f"Resolved variant {variant} not in variants config."
147+
)
148+
149+
except ReferenceError:
150+
raise ParseError(f"Invalid targeting {targeting}") from ReferenceError
147151

148152
variant, value = flag.get_variant(variant)
149153
if value is None:
150-
raise ParseError(f"Resolved variant {variant} not in variants config.")
154+
raise GeneralError(f"Resolved variant {variant} not in variants config.")
151155

152156
return FlagResolutionDetails(
153157
value,
154158
variant=variant,
155159
reason=Reason.TARGETING_MATCH,
156160
flag_metadata=metadata,
157161
)
162+
163+
164+
def _default_resolve(
165+
flag: Flag,
166+
metadata: typing.Mapping[str, typing.Union[float, int, str, bool]],
167+
reason: Reason,
168+
) -> FlagResolutionDetails:
169+
variant, value = flag.default
170+
if variant is None:
171+
return FlagResolutionDetails(
172+
value,
173+
variant=variant,
174+
reason=Reason.ERROR,
175+
error_code=ErrorCode.FLAG_NOT_FOUND,
176+
flag_metadata=metadata,
177+
)
178+
if variant not in flag.variants:
179+
raise GeneralError(f"Resolved variant {variant} not in variants config.")
180+
return FlagResolutionDetails(
181+
value, variant=variant, flag_metadata=metadata, reason=reason
182+
)

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,30 +72,22 @@ class Flag:
7272
key: str
7373
state: str
7474
variants: typing.Mapping[str, typing.Any]
75-
default_variant: typing.Union[bool, str]
75+
default_variant: typing.Optional[typing.Union[bool, str]] = None
7676
targeting: typing.Optional[dict] = None
7777
metadata: typing.Optional[
7878
typing.Mapping[str, typing.Union[float, int, str, bool]]
7979
] = None
8080

8181
def __post_init__(self) -> None:
82-
if not self.state or not isinstance(self.state, str):
82+
if not self.state or not (self.state == "ENABLED" or self.state == "DISABLED"):
8383
raise ParseError("Incorrect 'state' value provided in flag config")
8484

8585
if not self.variants or not isinstance(self.variants, dict):
8686
raise ParseError("Incorrect 'variants' value provided in flag config")
8787

88-
if not self.default_variant or not isinstance(
89-
self.default_variant, (str, bool)
90-
):
88+
if self.default_variant and not isinstance(self.default_variant, (str, bool)):
9189
raise ParseError("Incorrect 'defaultVariant' value provided in flag config")
9290

93-
if self.targeting and not isinstance(self.targeting, dict):
94-
raise ParseError("Incorrect 'targeting' value provided in flag config")
95-
96-
if self.default_variant not in self.variants:
97-
raise ParseError("Default variant does not match set of variants")
98-
9991
if self.metadata:
10092
if not isinstance(self.metadata, dict):
10193
raise ParseError("Flag metadata is not a valid json object")
@@ -106,6 +98,8 @@ def __post_init__(self) -> None:
10698
def from_dict(cls, key: str, data: dict) -> "Flag":
10799
if "defaultVariant" in data:
108100
data["default_variant"] = data["defaultVariant"]
101+
if data["default_variant"] == "":
102+
data["default_variant"] = None
109103
del data["defaultVariant"]
110104

111105
data.pop("source", None)
@@ -119,13 +113,16 @@ def from_dict(cls, key: str, data: dict) -> "Flag":
119113
raise ParseError from err
120114

121115
@property
122-
def default(self) -> tuple[str, typing.Any]:
116+
def default(self) -> tuple[typing.Optional[str], typing.Any]:
123117
return self.get_variant(self.default_variant)
124118

125119
def get_variant(
126-
self, variant_key: typing.Union[str, bool]
127-
) -> tuple[str, typing.Any]:
120+
self, variant_key: typing.Union[str, bool, None]
121+
) -> tuple[typing.Optional[str], typing.Any]:
128122
if isinstance(variant_key, bool):
129123
variant_key = str(variant_key).lower()
130124

125+
if not variant_key:
126+
return None, None
127+
131128
return variant_key, self.variants.get(variant_key)

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/targeting.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from json_logic.types import JsonValue
66

77
from openfeature.evaluation_context import EvaluationContext
8+
from openfeature.exception import ParseError
89

910
from .custom_ops import (
1011
ends_with,
@@ -27,6 +28,9 @@ def targeting(
2728
targeting: dict,
2829
evaluation_context: typing.Optional[EvaluationContext] = None,
2930
) -> JsonValue:
31+
if not isinstance(targeting, dict):
32+
raise ParseError(f"Invalid 'targeting' value in flag: {targeting}")
33+
3034
json_logic_context = evaluation_context.attributes if evaluation_context else {}
3135
json_logic_context["$flagd"] = {"flagKey": key, "timestamp": int(time.time())}
3236
json_logic_context["targetingKey"] = (

providers/openfeature-provider-flagd/tests/e2e/flagd_container.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def start(self) -> "FlagdContainer":
4848
return self
4949

5050
@wait_container_is_ready(ConnectionError)
51-
def _checker(self, host: str, port: str) -> None:
51+
def _checker(self, host: str, port: int) -> None:
5252
# First we wait for Flagd to say it's listening
5353
wait_for_logs(
5454
self,
@@ -58,7 +58,7 @@ def _checker(self, host: str, port: str) -> None:
5858

5959
time.sleep(1)
6060
# Second we use the GRPC health check endpoint
61-
with grpc.insecure_channel(host + ":" + port) as channel:
61+
with grpc.insecure_channel(host + ":" + str(port)) as channel:
6262
health_stub = health_pb2_grpc.HealthStub(channel)
6363

6464
def health_check_call(stub: health_pb2_grpc.HealthStub):

providers/openfeature-provider-flagd/tests/e2e/step/context_steps.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ def update_context(
3636
evaluation_context.attributes[key] = type_cast[type_info](value)
3737

3838

39+
@given(
40+
parsers.cfparse(
41+
'a context containing a key "{key}", with type "{type_info}" and with value ""'
42+
),
43+
)
44+
def update_context_without_value(
45+
evaluation_context: EvaluationContext, key: str, type_info: str
46+
):
47+
"""a context containing a key and value."""
48+
update_context(evaluation_context, key, type_info, "")
49+
50+
3951
@when(
4052
parsers.cfparse(
4153
'context contains keys {fields:s} with values "{svalue}", "{svalue2}", {ivalue:d}, "{bvalue:bool}"',

providers/openfeature-provider-flagd/tests/e2e/step/flag_step.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import requests
2-
from asserts import assert_equal
2+
from asserts import assert_equal, assert_is_none
33
from pytest_bdd import given, parsers, then, when
44

55
from openfeature.client import OpenFeatureClient
@@ -96,6 +96,25 @@ def resolve_details_reason(
9696
assert_equal(details.reason, Reason(reason))
9797

9898

99+
@then(
100+
parsers.cfparse('the error-code should be "{error_code}"'),
101+
)
102+
def resolve_details_error_code(
103+
details: FlagEvaluationDetails[JsonPrimitive],
104+
error_code: str,
105+
):
106+
assert_equal(details.error_code, error_code)
107+
108+
109+
@then(
110+
parsers.cfparse('the error-code should be ""'),
111+
)
112+
def resolve_details_empty_error_code(
113+
details: FlagEvaluationDetails[JsonPrimitive],
114+
):
115+
assert_is_none(details.error_code)
116+
117+
99118
@then(parsers.cfparse("the resolved metadata should contain"))
100119
def metadata_contains(details: FlagEvaluationDetails[JsonPrimitive], datatable):
101120
assert_equal(len(details.flag_metadata), len(datatable) - 1) # skip table header
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"flags": {
3+
"basic-flag": {
4+
"state": "ENABLED",
5+
"variants": {
6+
"true": true,
7+
"false": false
8+
},
9+
"targeting": {}
10+
}
11+
}
12+
}

providers/openfeature-provider-flagd/tests/test_errors.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,9 @@ def create_client(provider: FlagdProvider):
2323
"not-a-flag.json",
2424
"basic-flag-wrong-structure.json",
2525
"basic-flag-invalid.not-json",
26-
"basic-flag-wrong-variant.json",
2726
"basic-flag-broken-state.json",
2827
"basic-flag-broken-variants.json",
2928
"basic-flag-broken-default.json",
30-
"basic-flag-broken-targeting.json",
3129
],
3230
)
3331
def test_file_load_errors(file_name: str):
@@ -46,6 +44,38 @@ def test_file_load_errors(file_name: str):
4644
assert res.error_code == ErrorCode.FLAG_NOT_FOUND
4745

4846

47+
def test_non_existent_variant():
48+
path = os.path.abspath(os.path.join(os.path.dirname(__file__), "./flags/"))
49+
client = create_client(
50+
FlagdProvider(
51+
resolver_type=ResolverType.IN_PROCESS,
52+
offline_flag_source_path=f"{path}/basic-flag-wrong-variant.json",
53+
)
54+
)
55+
56+
res = client.get_boolean_details("basic-flag", False)
57+
58+
assert res.value is False
59+
assert res.reason == Reason.ERROR
60+
assert res.error_code == ErrorCode.GENERAL
61+
62+
63+
def test_broken_targeting():
64+
path = os.path.abspath(os.path.join(os.path.dirname(__file__), "./flags/"))
65+
client = create_client(
66+
FlagdProvider(
67+
resolver_type=ResolverType.IN_PROCESS,
68+
offline_flag_source_path=f"{path}/basic-flag-broken-targeting.json",
69+
)
70+
)
71+
72+
res = client.get_boolean_details("basic-flag", False)
73+
74+
assert res.value is False
75+
assert res.reason == Reason.ERROR
76+
assert res.error_code == ErrorCode.PARSE_ERROR
77+
78+
4979
@pytest.mark.parametrize(
5080
"file_name",
5181
[

0 commit comments

Comments
 (0)