Skip to content
Open
39 changes: 39 additions & 0 deletions providers/openfeature-provider-flagd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ The default options can be defined in the FlagdProvider constructor.
| retry_backoff_ms | FLAGD_RETRY_BACKOFF_MS | int | 1000 | rpc |
| offline_flag_source_path | FLAGD_OFFLINE_FLAG_SOURCE_PATH | str | null | in-process |

> [!NOTE]
> 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.

<!-- not implemented
| target_uri | FLAGD_TARGET_URI | alternative to host/port, supporting custom name resolution | string | null | rpc & in-process |
| socket_path | FLAGD_SOCKET_PATH | alternative to host port, unix socket | String | null | rpc & in-process |
Expand All @@ -103,6 +106,42 @@ The default options can be defined in the FlagdProvider constructor.
> [!NOTE]
> Some configurations are only applicable for RPC resolver.

### Selector Handling (In-Process Mode Only)

> [!IMPORTANT]
> This section only applies to **in-process** and **file** resolver modes. RPC mode is not affected by selector handling changes.

#### Current Implementation

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.

**Configuration Example:**
```python
from openfeature import api
from openfeature.contrib.provider.flagd import FlagdProvider
from openfeature.contrib.provider.flagd.config import ResolverType

api.set_provider(FlagdProvider(
resolver_type=ResolverType.IN_PROCESS,
selector="my-flag-source", # Passed via both header and request body
))
```

The selector is automatically passed via:
- **gRPC metadata header** (`flagd-selector`) - For flagd v0.11.0+ selector normalization
- **Request body** - For backward compatibility with older flagd versions

#### Backward Compatibility

This dual transmission approach ensures the Python SDK works seamlessly with all flagd service versions:
- **Older flagd versions** read the selector from the request body
- **Newer flagd versions (v0.11.0+)** prefer the selector from the gRPC metadata header
- Both approaches are supported simultaneously for maximum compatibility

**Related Resources:**
- Upstream issue: [open-feature/flagd#1814](https://github.com/open-feature/flagd/issues/1814)
- Selector normalization affects in-process evaluations that filter flag configurations by source

<!--
### Unix socket support
Unix socket communication with flagd is facilitated by usaging of the linux-native `epoll` library on `linux-x86_64`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ def __init__( # noqa: PLR0913
:param deadline_ms: the maximum to wait before a request times out
:param timeout: the maximum time to wait before a request times out
:param retry_backoff_ms: the number of milliseconds to backoff
:param selector: filter flag configurations by source (in-process mode only)
Passed via both flagd-selector gRPC metadata header and request body
for backward compatibility with all flagd versions.
:param offline_flag_source_path: the path to the flag source file
:param stream_deadline_ms: the maximum time to wait before a request times out
:param keep_alive_time: the number of milliseconds to keep alive
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,27 @@ def shutdown(self) -> None:

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

return request_args

def _create_metadata(self) -> typing.Optional[tuple[tuple[str, str]]]:
"""Create gRPC metadata headers for the request.

Returns gRPC metadata as a list of tuples containing header key-value pairs.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to make sure, should it be tuples of tuples like the implementation is indicating or should the implementation change to return an actual list of tuples?

The selector is passed via the 'flagd-selector' header per flagd v0.11.0+ specification,
while also being included in the request body for backward compatibility with older flagd versions.
"""
if self.selector is None:
return None

return (("flagd-selector", self.selector),)

def _fetch_metadata(self) -> typing.Optional[sync_pb2.GetMetadataResponse]:
if self.config.sync_metadata_disabled:
return None
Expand All @@ -229,10 +243,9 @@ def _fetch_metadata(self) -> typing.Optional[sync_pb2.GetMetadataResponse]:
else:
raise e

def listen(self) -> None: # noqa: C901
call_args: GrpcMultiCallableArgs = {"wait_for_ready": True}
if self.streamline_deadline_seconds > 0:
call_args["timeout"] = self.streamline_deadline_seconds
def listen(self) -> None:
call_args = self.generate_grpc_call_args()

request_args = self._create_request_args()

while self.active:
Expand Down Expand Up @@ -279,3 +292,13 @@ def listen(self) -> None: # noqa: C901
logger.exception(
f"Could not parse flag data using flagd syntax: {flag_str=}"
)

def generate_grpc_call_args(self) -> GrpcMultiCallableArgs:
call_args: GrpcMultiCallableArgs = {"wait_for_ready": True}
if self.streamline_deadline_seconds > 0:
call_args["timeout"] = self.streamline_deadline_seconds
# Add selector via gRPC metadata header (flagd v0.11.0+ preferred approach)
metadata = self._create_metadata()
if metadata is not None:
call_args["metadata"] = metadata
return call_args
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
class GrpcMultiCallableArgs(typing.TypedDict, total=False):
timeout: typing.Optional[float]
wait_for_ready: typing.Optional[bool]
metadata: typing.Optional[tuple[tuple[str, str]]]
28 changes: 28 additions & 0 deletions providers/openfeature-provider-flagd/tests/test_grpc_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,31 @@ def test_listen_with_sync_metadata_disabled_in_config(self):
self.provider_details.message, "gRPC sync connection established"
)
self.assertEqual(self.context, {})

def test_selector_passed_via_both_metadata_and_body(self):
"""Test that selector is passed via both gRPC metadata header and request body for backward compatibility"""
self.grpc_watcher.selector = "test-selector"
mock_stream = iter(
[
SyncFlagsResponse(flag_configuration='{"flag_key": "flag_value"}'),
]
)
self.mock_stub.SyncFlags = Mock(return_value=mock_stream)

self.run_listen_and_shutdown_after()

# Verify SyncFlags was called
self.mock_stub.SyncFlags.assert_called()

# Get the call arguments
call_args = self.mock_stub.SyncFlags.call_args

# Verify the request contains selector in body (backward compatibility)
request = call_args.args[0] # First positional argument is the request
self.assertEqual(request.selector, "test-selector")

# Verify metadata also contains flagd-selector header (new approach)
kwargs = call_args.kwargs
self.assertIn("metadata", kwargs)
metadata = kwargs["metadata"]
self.assertEqual(metadata, (("flagd-selector", "test-selector"),))