From 4593fb56adbd1a114f2e791cd2423170837780a5 Mon Sep 17 00:00:00 2001 From: ali Date: Fri, 27 Feb 2026 10:14:46 +0530 Subject: [PATCH 1/4] UN-3291 [FIX] Migrate Flipt v1 to v2 and fix gRPC client bugs for feature flags Migrate Docker dev essentials Flipt from v1.34.0 to v2.3.1 with file-based storage, and fix three gRPC client bugs introduced in PR #1665 that caused all feature flag evaluations to silently return False. Co-Authored-By: Claude Opus 4.6 --- docker/docker-compose-dev-essentials.yaml | 10 +++------- docker/sample.essentials.env | 3 --- unstract/flags/src/unstract/flags/client/flipt.py | 2 +- unstract/flags/src/unstract/flags/feature_flag.py | 2 +- .../src/unstract/flags/flipt_grpc/flipt/__init__.py | 9 +++++++++ 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docker/docker-compose-dev-essentials.yaml b/docker/docker-compose-dev-essentials.yaml index e5b308eefc..d0ec7b4ba2 100644 --- a/docker/docker-compose-dev-essentials.yaml +++ b/docker/docker-compose-dev-essentials.yaml @@ -92,18 +92,14 @@ services: - "host.docker.internal:host-gateway" feature-flag: - image: flipt/flipt:v1.34.0 # Dated(05/01/2024) Latest stable version. Ref:https://github.com/flipt-io/flipt/releases + image: docker.flipt.io/flipt/flipt:v2.3.1 container_name: unstract-flipt restart: unless-stopped ports: # Forwarded to available host ports - "8082:8080" # REST API port - "9005:9000" # gRPC port - # https://www.flipt.io/docs/configuration/overview#environment-variables) - # https://www.flipt.io/docs/configuration/overview#configuration-parameters - env_file: - - ./essentials.env - environment: - FLIPT_CACHE_ENABLED: true + volumes: + - flipt_data:/var/opt/flipt labels: - traefik.enable=true - traefik.http.routers.feature-flag.rule=Host(`feature-flag.unstract.localhost`) diff --git a/docker/sample.essentials.env b/docker/sample.essentials.env index e5adb46786..51876fb8f9 100644 --- a/docker/sample.essentials.env +++ b/docker/sample.essentials.env @@ -10,9 +10,6 @@ MINIO_ROOT_PASSWORD=minio123 MINIO_ACCESS_KEY=minio MINIO_SECRET_KEY=minio123 -# Use encoded password Refer : https://docs.sqlalchemy.org/en/20/core/engines.html#escaping-special-characters-such-as-signs-in-passwords -FLIPT_DB_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable" - QDRANT_USER=unstract_vector_dev QDRANT_PASS=unstract_vector_pass QDRANT_DB=unstract_vector_db diff --git a/unstract/flags/src/unstract/flags/client/flipt.py b/unstract/flags/src/unstract/flags/client/flipt.py index fa2910c913..11ed0c6b37 100644 --- a/unstract/flags/src/unstract/flags/client/flipt.py +++ b/unstract/flags/src/unstract/flags/client/flipt.py @@ -68,7 +68,7 @@ def evaluate_boolean( flag_key=flag_key, entity_id=entity_id, context=context or {} ) - return result.value if hasattr(result, "value") else False + return result.enabled if hasattr(result, "enabled") else False except Exception as e: logger.error(f"Error evaluating flag {flag_key} for entity {entity_id}: {e}") diff --git a/unstract/flags/src/unstract/flags/feature_flag.py b/unstract/flags/src/unstract/flags/feature_flag.py index 4776f5bd29..f327e9df3b 100644 --- a/unstract/flags/src/unstract/flags/feature_flag.py +++ b/unstract/flags/src/unstract/flags/feature_flag.py @@ -42,6 +42,6 @@ def check_feature_flag_status( context=context or {}, ) - return bool(result.enabled) + return bool(result) except Exception: return False diff --git a/unstract/flags/src/unstract/flags/flipt_grpc/flipt/__init__.py b/unstract/flags/src/unstract/flags/flipt_grpc/flipt/__init__.py index 2fd9f1a7b1..55a203097f 100644 --- a/unstract/flags/src/unstract/flags/flipt_grpc/flipt/__init__.py +++ b/unstract/flags/src/unstract/flags/flipt_grpc/flipt/__init__.py @@ -1 +1,10 @@ """Flipt gRPC protobuf definitions.""" + +# Pre-register the Timestamp well-known type in the protobuf descriptor pool. +# Required because flipt_simple_pb2.py and evaluation_simple_pb2.py declare +# a dependency on google/protobuf/timestamp.proto in their serialized descriptors. +# Without this, AddSerializedFile() fails with KeyError (pure-Python) or TypeError (C/upb). +# In the backend, this happens to work because Google Cloud libraries (google-cloud-storage, +# etc.) import timestamp_pb2 as a side effect during Django startup. Workers don't have +# that implicit dependency, so we must be explicit. +from google.protobuf import timestamp_pb2 as _timestamp_pb2 # noqa: F401 From 2bff6947a19d788373e0cf36f3a17d7145785e5c Mon Sep 17 00:00:00 2001 From: ali Date: Fri, 27 Feb 2026 11:32:53 +0530 Subject: [PATCH 2/4] UN-3291 [DOCS] Add docstring for check_feature_flag_variant() Co-Authored-By: Claude Opus 4.6 --- .../flags/src/unstract/flags/client/flipt.py | 71 ++++++++++++++ .../flags/src/unstract/flags/feature_flag.py | 96 +++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/unstract/flags/src/unstract/flags/client/flipt.py b/unstract/flags/src/unstract/flags/client/flipt.py index 11ed0c6b37..cec1900193 100644 --- a/unstract/flags/src/unstract/flags/client/flipt.py +++ b/unstract/flags/src/unstract/flags/client/flipt.py @@ -81,6 +81,77 @@ def evaluate_boolean( except Exception as e: logger.error(f"Error closing Flipt client: {e}") + def evaluate_variant( + self, + flag_key: str, + entity_id: str | None = "unstract", + context: dict | None = None, + namespace_key: str | None = None, + ) -> dict: + """Evaluate a variant feature flag for a given entity. + + Args: + flag_key: The key of the feature flag to evaluate + entity_id: The ID of the entity for which to evaluate the flag + context: Additional context for evaluation + namespace_key: The namespace to evaluate the flag in + + Returns: + dict: {"match": bool, "variant_key": str, "variant_attachment": str, + "segment_keys": list[str]} + Returns empty dict with match=False if service unavailable or error. + """ + default_result = { + "match": False, + "variant_key": "", + "variant_attachment": "", + "segment_keys": [], + } + if namespace_key is not None: + warnings.warn( + "namespace_key parameter is deprecated and will be ignored", + DeprecationWarning, + stacklevel=2, + ) + if not self.service_available: + logger.warning("Flipt service not available, returning default for all flags") + return default_result + + client = None + try: + client = FliptGrpcClient(opts=self.grpc_opts) + + result = client.evaluate_variant( + flag_key=flag_key, entity_id=entity_id, context=context or {} + ) + + return { + "match": result.match if hasattr(result, "match") else False, + "variant_key": ( + result.variant_key if hasattr(result, "variant_key") else "" + ), + "variant_attachment": ( + result.variant_attachment + if hasattr(result, "variant_attachment") + else "" + ), + "segment_keys": ( + list(result.segment_keys) if hasattr(result, "segment_keys") else [] + ), + } + + except Exception as e: + logger.error( + f"Error evaluating variant flag {flag_key} for entity {entity_id}: {e}" + ) + return default_result + finally: + if client: + try: + client.close() + except Exception as e: + logger.error(f"Error closing Flipt client: {e}") + def list_feature_flags(self, namespace_key: str | None = None) -> dict: """List all feature flags in a namespace. diff --git a/unstract/flags/src/unstract/flags/feature_flag.py b/unstract/flags/src/unstract/flags/feature_flag.py index f327e9df3b..bf53bcf25e 100644 --- a/unstract/flags/src/unstract/flags/feature_flag.py +++ b/unstract/flags/src/unstract/flags/feature_flag.py @@ -45,3 +45,99 @@ def check_feature_flag_status( return bool(result) except Exception: return False + + +def check_feature_flag_variant( + flag_key: str, + namespace_key: str | None = None, + entity_id: str = "unstract", + context: dict[str, str] | None = None, +) -> dict: + """Check a variant feature flag and return its evaluation result. + + Evaluates a Flipt variant flag and returns the full evaluation response. + The function first checks whether the flag is enabled before calling + Flipt's variant evaluation API. + + Args: + flag_key: The flag key of the feature flag. + namespace_key: The namespace key of the feature flag. + If None, reads UNSTRACT_FEATURE_FLAG_NAMESPACE env var, + falling back to "default". + entity_id: An identifier for the evaluation entity. Used by Flipt + for consistent percentage-based rollout hashing only — it does + NOT influence segment constraint matching. + context: Key-value pairs matched against Flipt segment constraints. + Keys must correspond exactly to the constraint property names + configured in Flipt. For example, if a segment has a constraint + on property "organization_id", pass + ``{"organization_id": "org_123"}``. Defaults to None. + + Returns: + dict with the following fields: + + - **enabled** (bool): Whether the flag is enabled in Flipt. + - **match** (bool): Whether the entity matched a segment rule. + - **variant_key** (str): The key of the matched variant (empty + string if no match). + - **variant_attachment** (str): JSON string attached to the variant + (empty string if no match). Parse with ``json.loads()`` to get + structured data. + - **segment_keys** (list[str]): Segment keys that were matched. + + Result interpretation: + - ``enabled=False`` → Flag is disabled or not found in Flipt. + All other fields are at their defaults. + - ``enabled=True, match=True`` → The entity's context matched a + segment rule and a variant was assigned. ``variant_key`` and + ``variant_attachment`` contain the assigned values. + - ``enabled=True, match=False`` → The flag is on but no segment + rule matched the provided context. This typically means Flipt + is missing Segments and/or Rules for this flag, or the context + keys/values don't satisfy any segment constraint. + + Note: + Variant flags in Flipt require three things to be configured for + ``match=True``: **Variants** (the possible values), **Segments** + (constraint-based groups), and **Rules** (which link segments to + variants). If any of these are missing, evaluation returns + ``match=False``. + + Example:: + + import json + + result = check_feature_flag_variant( + flag_key="extraction_engine", + context={"organization_id": "org_123"}, + ) + if result["enabled"] and result["match"]: + config = json.loads(result["variant_attachment"]) + engine = config["engine"] + """ + default_result = { + "enabled": False, + "match": False, + "variant_key": "", + "variant_attachment": "", + "segment_keys": [], + } + try: + client = FliptClient() + + # Check enabled status first + flags = client.list_feature_flags() + if not flags.get("flags", {}).get(flag_key, False): + return default_result + + # Flag is enabled, evaluate variant + result = client.evaluate_variant( + flag_key=flag_key, + entity_id=entity_id, + context=context or {}, + ) + result["enabled"] = True + + return result + except Exception: + return default_result From b9afabd55c304175a8d24da6252fb9dcd9602312 Mon Sep 17 00:00:00 2001 From: ali Date: Wed, 4 Mar 2026 16:11:04 +0530 Subject: [PATCH 3/4] UN-3291 [FIX] Remove deprecated namespace_key param from new evaluate_variant method Since evaluate_variant is a new method, there's no need to include the deprecated namespace_key parameter. The namespace is already configured at the client level via UNSTRACT_FEATURE_FLAG_NAMESPACE. Co-Authored-By: Claude Opus 4.6 --- unstract/flags/src/unstract/flags/client/flipt.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/unstract/flags/src/unstract/flags/client/flipt.py b/unstract/flags/src/unstract/flags/client/flipt.py index cec1900193..23c5f64df3 100644 --- a/unstract/flags/src/unstract/flags/client/flipt.py +++ b/unstract/flags/src/unstract/flags/client/flipt.py @@ -86,7 +86,6 @@ def evaluate_variant( flag_key: str, entity_id: str | None = "unstract", context: dict | None = None, - namespace_key: str | None = None, ) -> dict: """Evaluate a variant feature flag for a given entity. @@ -94,7 +93,6 @@ def evaluate_variant( flag_key: The key of the feature flag to evaluate entity_id: The ID of the entity for which to evaluate the flag context: Additional context for evaluation - namespace_key: The namespace to evaluate the flag in Returns: dict: {"match": bool, "variant_key": str, "variant_attachment": str, @@ -107,12 +105,6 @@ def evaluate_variant( "variant_attachment": "", "segment_keys": [], } - if namespace_key is not None: - warnings.warn( - "namespace_key parameter is deprecated and will be ignored", - DeprecationWarning, - stacklevel=2, - ) if not self.service_available: logger.warning("Flipt service not available, returning default for all flags") return default_result From dd7cac8f2daae50c65806cb9d8e35bf816f87061 Mon Sep 17 00:00:00 2001 From: ali Date: Wed, 4 Mar 2026 16:17:15 +0530 Subject: [PATCH 4/4] UN-3291 [FIX] Remove unused namespace_key param from check_feature_flag_variant New method doesn't need the deprecated parameter since the namespace is configured at the FliptClient level via UNSTRACT_FEATURE_FLAG_NAMESPACE. Co-Authored-By: Claude Opus 4.6 --- unstract/flags/src/unstract/flags/feature_flag.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/unstract/flags/src/unstract/flags/feature_flag.py b/unstract/flags/src/unstract/flags/feature_flag.py index bf53bcf25e..f9833c3aa2 100644 --- a/unstract/flags/src/unstract/flags/feature_flag.py +++ b/unstract/flags/src/unstract/flags/feature_flag.py @@ -49,7 +49,6 @@ def check_feature_flag_status( def check_feature_flag_variant( flag_key: str, - namespace_key: str | None = None, entity_id: str = "unstract", context: dict[str, str] | None = None, ) -> dict: @@ -61,9 +60,6 @@ def check_feature_flag_variant( Args: flag_key: The flag key of the feature flag. - namespace_key: The namespace key of the feature flag. - If None, reads UNSTRACT_FEATURE_FLAG_NAMESPACE env var, - falling back to "default". entity_id: An identifier for the evaluation entity. Used by Flipt for consistent percentage-based rollout hashing only — it does NOT influence segment constraint matching.