Skip to content

Commit e1c3c6a

Browse files
feat: make use of flagd-selector header in RPC mode (#396)
* feat(flagd): send flagd-selector header on RPC calls RPC resolver now attaches the `flagd-selector` gRPC metadata header on every unary call and on the EventStream when a selector is configured, so the flagd server can serve the requested flag set. Brings the Python RPC resolver to parity with the Java and JS providers. Signed-off-by: Todd Baert <todd.baert@dynatrace.com> * refactor(flagd): precompute selector metadata and extract stream call args helper Reduces listen() complexity below ruff C901 threshold while keeping the flagd-selector header wiring intact. Signed-off-by: Todd Baert <todd.baert@dynatrace.com> * fix(flagd): bump grpcio>=1.81.0 to match generated stubs The flagd evaluation v2 generated stubs require grpcio>=1.81.0 (RuntimeError at import time otherwise). The pyproject constraint and lockfile were still pinned to 1.80.0, breaking CI on main. Signed-off-by: Todd Baert <todd.baert@dynatrace.com> * fix(flagd): widen metadata TypedDict and cover EventStream selector - types.py: metadata is now variadic tuple[tuple[str, str], ...] to allow >1 entry and satisfy mypy.\n- tests: import FLAGD_SELECTOR_HEADER from the resolver instead of hardcoding, and add coverage for the EventStream listen() path (both with and without selector). Signed-off-by: Todd Baert <todd.baert@dynatrace.com> * fix(flagd): use contextlib.suppress to satisfy ruff S110/SIM105 Signed-off-by: Todd Baert <todd.baert@dynatrace.com> * Update providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Todd Baert <todd.baert@dynatrace.com> * style(flagd): apply ruff format to selector_metadata expression Signed-off-by: Todd Baert <todd.baert@dynatrace.com> --------- Signed-off-by: Todd Baert <todd.baert@dynatrace.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent c4b4257 commit e1c3c6a

5 files changed

Lines changed: 150 additions & 58 deletions

File tree

providers/openfeature-provider-flagd/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ keywords = []
1919
dependencies = [
2020
"openfeature-sdk>=0.8.2",
2121
"openfeature-flagd-core>=1.0.0,<2",
22-
"grpcio>=1.80.0",
22+
"grpcio>=1.81.0",
2323
"protobuf>=6.30.0,<7.0.0",
2424
"pyyaml>=6.0.1",
2525
"cachebox>=5.1.0,<6.0.0",

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
from ..flag_type import FlagType
3333
from .types import GrpcMultiCallableArgs
3434

35+
FLAGD_SELECTOR_HEADER = "flagd-selector"
36+
3537
if typing.TYPE_CHECKING:
3638
from google.protobuf.message import Message
3739

@@ -71,6 +73,9 @@ def __init__(
7173
self._is_fatal = False
7274
self.channel = self._generate_channel(config)
7375
self.stub = evaluation_pb2_grpc.ServiceStub(self.channel)
76+
self.selector_metadata: tuple[tuple[str, str], ...] | None = (
77+
((FLAGD_SELECTOR_HEADER, config.selector),) if config.selector else None
78+
)
7479

7580
self.thread: threading.Thread | None = None
7681
self.timer: threading.Timer | None = None
@@ -215,11 +220,17 @@ def emit_error(self) -> None:
215220
)
216221
)
217222

218-
def listen(self) -> None:
219-
logger.debug("gRPC starting listener thread")
223+
def _stream_call_args(self) -> GrpcMultiCallableArgs:
220224
call_args: GrpcMultiCallableArgs = {"wait_for_ready": True}
221225
if self.streamline_deadline_seconds > 0:
222226
call_args["timeout"] = self.streamline_deadline_seconds
227+
if self.selector_metadata is not None:
228+
call_args["metadata"] = self.selector_metadata
229+
return call_args
230+
231+
def listen(self) -> None:
232+
logger.debug("gRPC starting listener thread")
233+
call_args = self._stream_call_args()
223234
request = evaluation_pb2.EventStreamRequest()
224235

225236
# defining a never ending loop to recreate the stream
@@ -377,6 +388,8 @@ def _resolve( # noqa: PLR0915 C901
377388
"timeout": self.deadline,
378389
"wait_for_ready": True,
379390
}
391+
if self.selector_metadata is not None:
392+
call_args["metadata"] = self.selector_metadata
380393
try:
381394
request: Message
382395
if flag_type == FlagType.BOOLEAN:

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
class GrpcMultiCallableArgs(typing.TypedDict, total=False):
55
timeout: float | None
66
wait_for_ready: bool | None
7-
metadata: tuple[tuple[str, str]] | None
7+
metadata: tuple[tuple[str, str], ...] | None
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from contextlib import suppress
2+
from unittest.mock import MagicMock, Mock
3+
4+
from openfeature.contrib.provider.flagd.config import Config
5+
from openfeature.contrib.provider.flagd.flag_type import FlagType
6+
from openfeature.contrib.provider.flagd.resolvers.grpc import (
7+
FLAGD_SELECTOR_HEADER,
8+
GrpcResolver,
9+
)
10+
from openfeature.schemas.protobuf.flagd.evaluation.v2 import evaluation_pb2
11+
12+
13+
def _make_resolver(selector):
14+
config = Config(host="localhost", port=8013, tls=False)
15+
config.selector = selector
16+
resolver = GrpcResolver(
17+
config=config,
18+
emit_provider_ready=Mock(),
19+
emit_provider_error=Mock(),
20+
emit_provider_stale=Mock(),
21+
emit_provider_configuration_changed=Mock(),
22+
)
23+
return resolver
24+
25+
26+
def test_unary_call_includes_selector_metadata_when_configured():
27+
resolver = _make_resolver("test-selector")
28+
mock_stub = MagicMock()
29+
mock_stub.ResolveBoolean = Mock(
30+
return_value=evaluation_pb2.ResolveBooleanResponse(value=True, reason="STATIC")
31+
)
32+
resolver.stub = mock_stub
33+
34+
resolver._resolve("flag", FlagType.BOOLEAN, False, None)
35+
36+
kwargs = mock_stub.ResolveBoolean.call_args.kwargs
37+
assert kwargs.get("metadata") == ((FLAGD_SELECTOR_HEADER, "test-selector"),)
38+
39+
40+
def test_unary_call_omits_metadata_when_no_selector():
41+
resolver = _make_resolver(None)
42+
mock_stub = MagicMock()
43+
mock_stub.ResolveBoolean = Mock(
44+
return_value=evaluation_pb2.ResolveBooleanResponse(value=True, reason="STATIC")
45+
)
46+
resolver.stub = mock_stub
47+
48+
resolver._resolve("flag", FlagType.BOOLEAN, False, None)
49+
50+
kwargs = mock_stub.ResolveBoolean.call_args.kwargs
51+
assert "metadata" not in kwargs
52+
53+
54+
def test_event_stream_includes_selector_metadata_when_configured():
55+
resolver = _make_resolver("test-selector")
56+
mock_stub = MagicMock()
57+
mock_stub.EventStream = Mock(side_effect=Exception("break loop"))
58+
resolver.stub = mock_stub
59+
resolver.active = True
60+
61+
with suppress(Exception):
62+
resolver.listen()
63+
64+
kwargs = mock_stub.EventStream.call_args.kwargs
65+
assert kwargs.get("metadata") == ((FLAGD_SELECTOR_HEADER, "test-selector"),)
66+
67+
68+
def test_event_stream_omits_metadata_when_no_selector():
69+
resolver = _make_resolver(None)
70+
mock_stub = MagicMock()
71+
mock_stub.EventStream = Mock(side_effect=Exception("break loop"))
72+
resolver.stub = mock_stub
73+
resolver.active = True
74+
75+
with suppress(Exception):
76+
resolver.listen()
77+
78+
kwargs = mock_stub.EventStream.call_args.kwargs
79+
assert "metadata" not in kwargs

0 commit comments

Comments
 (0)