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.
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"),))