Skip to content

Commit 9ad25a4

Browse files
Copilotaepfligemini-code-assist[bot]
authored
feat(flagd): Implement dual-mode selector handling (header and body) for in-process mode in python-sdk-contrib (#320)
* Initial plan Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * Document selector migration guidance for in-process mode - Add comprehensive migration guidance section for selector handling - Clarify that selector changes only affect in-process and file modes - Document backward compatibility and future breaking changes - Update provider docstring with selector migration note - Link to upstream flagd issue #1814 Co-authored-by: aepfli <9987394+aepfli@users.noreply.github.com> Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * Implement selector passing via gRPC metadata header for in-process mode - Update GrpcWatcher to pass selector via flagd-selector metadata header - Add GrpcMultiCallableArgs.metadata field to support gRPC metadata - Update README to reflect current header-based implementation - Add test to verify selector is passed via metadata, not request body - Aligns with flagd v0.11.0+ selector normalization standard Co-authored-by: aepfli <9987394+aepfli@users.noreply.github.com> Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * Pass selector via both header and request body for backward compatibility - Update _create_request_args to include selector in request body - Maintain selector in flagd-selector gRPC metadata header - Update test to verify both header and body contain selector - Update documentation to reflect dual transmission approach - Ensures compatibility with both older and newer flagd versions Co-authored-by: aepfli <9987394+aepfli@users.noreply.github.com> Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * Update providers/openfeature-provider-flagd/tests/test_grpc_watcher.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * fixup: linting Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * fixup: types Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * fixup: linting Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * fixup: more formating Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * fixup: changing assertion Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * fixup: changing assertion Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * Update providers/openfeature-provider-flagd/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> * Update providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/connector/grpc_watcher.py Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> --------- Signed-off-by: Simon Schrottner <simon.schrottner@dynatrace.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aepfli <9987394+aepfli@users.noreply.github.com> Co-authored-by: Simon Schrottner <simon.schrottner@dynatrace.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 7b83b94 commit 9ad25a4

File tree

5 files changed

+98
-4
lines changed

5 files changed

+98
-4
lines changed

providers/openfeature-provider-flagd/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ The default options can be defined in the FlagdProvider constructor.
9393
| retry_backoff_ms | FLAGD_RETRY_BACKOFF_MS | int | 1000 | rpc |
9494
| offline_flag_source_path | FLAGD_OFFLINE_FLAG_SOURCE_PATH | str | null | in-process |
9595

96+
> [!NOTE]
97+
> The `selector` configuration is only used in **in-process** mode for filtering flag configurations. See [Selector Handling](#selector-handling-in-process-mode-only) for migration guidance.
98+
9699
<!-- not implemented
97100
| target_uri | FLAGD_TARGET_URI | alternative to host/port, supporting custom name resolution | string | null | rpc & in-process |
98101
| socket_path | FLAGD_SOCKET_PATH | alternative to host port, unix socket | String | null | rpc & in-process |
@@ -103,6 +106,42 @@ The default options can be defined in the FlagdProvider constructor.
103106
> [!NOTE]
104107
> Some configurations are only applicable for RPC resolver.
105108
109+
### Selector Handling (In-Process Mode Only)
110+
111+
> [!IMPORTANT]
112+
> This section only applies to **in-process** resolver mode. RPC mode is not affected by selector handling changes.
113+
114+
#### Current Implementation
115+
116+
As of this SDK version, the `selector` parameter is passed via **both** gRPC metadata headers (`flagd-selector`) and the request body when using in-process mode. This dual approach ensures maximum compatibility with all flagd versions.
117+
118+
**Configuration Example:**
119+
```python
120+
from openfeature import api
121+
from openfeature.contrib.provider.flagd import FlagdProvider
122+
from openfeature.contrib.provider.flagd.config import ResolverType
123+
124+
api.set_provider(FlagdProvider(
125+
resolver_type=ResolverType.IN_PROCESS,
126+
selector="my-flag-source", # Passed via both header and request body
127+
))
128+
```
129+
130+
The selector is automatically passed via:
131+
- **gRPC metadata header** (`flagd-selector`) - For flagd v0.11.0+ selector normalization
132+
- **Request body** - For backward compatibility with older flagd versions
133+
134+
#### Backward Compatibility
135+
136+
This dual transmission approach ensures the Python SDK works seamlessly with all flagd service versions:
137+
- **Older flagd versions** read the selector from the request body
138+
- **Newer flagd versions (v0.11.0+)** prefer the selector from the gRPC metadata header
139+
- Both approaches are supported simultaneously for maximum compatibility
140+
141+
**Related Resources:**
142+
- Upstream issue: [open-feature/flagd#1814](https://github.com/open-feature/flagd/issues/1814)
143+
- Selector normalization affects in-process evaluations that filter flag configurations by source
144+
106145
<!--
107146
### Unix socket support
108147
Unix socket communication with flagd is facilitated by usaging of the linux-native `epoll` library on `linux-x86_64`

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ def __init__( # noqa: PLR0913
7575
:param deadline_ms: the maximum to wait before a request times out
7676
:param timeout: the maximum time to wait before a request times out
7777
:param retry_backoff_ms: the number of milliseconds to backoff
78+
:param selector: filter flag configurations by source (in-process mode only)
79+
Passed via both flagd-selector gRPC metadata header and request body
80+
for backward compatibility with all flagd versions.
7881
:param offline_flag_source_path: the path to the flag source file
7982
:param stream_deadline_ms: the maximum time to wait before a request times out
8083
:param keep_alive_time: the number of milliseconds to keep alive

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,27 @@ def shutdown(self) -> None:
205205

206206
def _create_request_args(self) -> dict:
207207
request_args = {}
208+
# Pass selector in both request body (legacy) and metadata header (new) for backward compatibility
209+
# This ensures compatibility with both older and newer flagd versions
208210
if self.selector is not None:
209211
request_args["selector"] = self.selector
210212
if self.provider_id is not None:
211213
request_args["provider_id"] = self.provider_id
212214

213215
return request_args
214216

217+
def _create_metadata(self) -> typing.Optional[tuple[tuple[str, str]]]:
218+
"""Create gRPC metadata headers for the request.
219+
220+
Returns gRPC metadata as a tuples of tuples containing header key-value pairs.
221+
The selector is passed via the 'flagd-selector' header per flagd v0.11.0+ specification,
222+
while also being included in the request body for backward compatibility with older flagd versions.
223+
"""
224+
if self.selector is None:
225+
return None
226+
227+
return (("flagd-selector", self.selector),)
228+
215229
def _fetch_metadata(self) -> typing.Optional[sync_pb2.GetMetadataResponse]:
216230
if self.config.sync_metadata_disabled:
217231
return None
@@ -229,10 +243,9 @@ def _fetch_metadata(self) -> typing.Optional[sync_pb2.GetMetadataResponse]:
229243
else:
230244
raise e
231245

232-
def listen(self) -> None: # noqa: C901
233-
call_args: GrpcMultiCallableArgs = {"wait_for_ready": True}
234-
if self.streamline_deadline_seconds > 0:
235-
call_args["timeout"] = self.streamline_deadline_seconds
246+
def listen(self) -> None:
247+
call_args = self.generate_grpc_call_args()
248+
236249
request_args = self._create_request_args()
237250

238251
while self.active:
@@ -279,3 +292,13 @@ def listen(self) -> None: # noqa: C901
279292
logger.exception(
280293
f"Could not parse flag data using flagd syntax: {flag_str=}"
281294
)
295+
296+
def generate_grpc_call_args(self) -> GrpcMultiCallableArgs:
297+
call_args: GrpcMultiCallableArgs = {"wait_for_ready": True}
298+
if self.streamline_deadline_seconds > 0:
299+
call_args["timeout"] = self.streamline_deadline_seconds
300+
# Add selector via gRPC metadata header (flagd v0.11.0+ preferred approach)
301+
metadata = self._create_metadata()
302+
if metadata is not None:
303+
call_args["metadata"] = metadata
304+
return call_args

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
class GrpcMultiCallableArgs(typing.TypedDict, total=False):
55
timeout: typing.Optional[float]
66
wait_for_ready: typing.Optional[bool]
7+
metadata: typing.Optional[tuple[tuple[str, str]]]

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,31 @@ def test_listen_with_sync_metadata_disabled_in_config(self):
133133
self.provider_details.message, "gRPC sync connection established"
134134
)
135135
self.assertEqual(self.context, {})
136+
137+
def test_selector_passed_via_both_metadata_and_body(self):
138+
"""Test that selector is passed via both gRPC metadata header and request body for backward compatibility"""
139+
self.grpc_watcher.selector = "test-selector"
140+
mock_stream = iter(
141+
[
142+
SyncFlagsResponse(flag_configuration='{"flag_key": "flag_value"}'),
143+
]
144+
)
145+
self.mock_stub.SyncFlags = Mock(return_value=mock_stream)
146+
147+
self.run_listen_and_shutdown_after()
148+
149+
# Verify SyncFlags was called
150+
self.mock_stub.SyncFlags.assert_called()
151+
152+
# Get the call arguments
153+
call_args = self.mock_stub.SyncFlags.call_args
154+
155+
# Verify the request contains selector in body (backward compatibility)
156+
request = call_args.args[0] # First positional argument is the request
157+
self.assertEqual(request.selector, "test-selector")
158+
159+
# Verify metadata also contains flagd-selector header (new approach)
160+
kwargs = call_args.kwargs
161+
self.assertIn("metadata", kwargs)
162+
metadata = kwargs["metadata"]
163+
self.assertEqual(metadata, (("flagd-selector", "test-selector"),))

0 commit comments

Comments
 (0)