Skip to content
12 changes: 8 additions & 4 deletions mixpanel/flags/local_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
from .types import (
ExperimentationFlag,
ExperimentationFlags,
FallbackReason,
LocalFlagsConfig,
Rollout,
SelectedVariant,
VariantSource,
)
from .utils import (
EXPOSURE_EVENT,
Expand Down Expand Up @@ -228,15 +230,17 @@ def get_variant(

if not flag_definition:
logger.warning("Cannot find flag definition for key: '%s'", flag_key)
return fallback_value
return fallback_value.as_fallback(FallbackReason.flag_not_found())

if not (context_value := context.get(flag_definition.context)):
logger.warning(
"The rollout context, '%s' for flag, '%s' is not present in the supplied context dictionary",
flag_definition.context,
flag_key,
)
return fallback_value
return fallback_value.as_fallback(
FallbackReason.missing_context_key(flag_definition.context)
)

selected_variant: SelectedVariant | None = None

Expand All @@ -257,15 +261,15 @@ def get_variant(
self._track_exposure(
flag_key, selected_variant, context, end_time - start_time
)
return selected_variant
return selected_variant.with_source(VariantSource.LOCAL)

logger.debug(
"%s context %s not eligible for any rollout for flag: %s",
flag_definition.context,
context_value,
flag_key,
)
return fallback_value
return fallback_value.as_fallback(FallbackReason.no_rollout_match())

def track_exposure_event(
self, flag_key: str, variant: SelectedVariant, context: dict[str, Any]
Expand Down
52 changes: 45 additions & 7 deletions mixpanel/flags/remote_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@

from mixpanel.credentials import ServiceAccountCredentials

from .types import RemoteFlagsConfig, RemoteFlagsResponse, SelectedVariant
from .types import (
FallbackReason,
RemoteFlagsConfig,
RemoteFlagsResponse,
SelectedVariant,
VariantSource,
)
from .utils import (
EXPOSURE_EVENT,
REQUEST_HEADERS,
Expand Down Expand Up @@ -94,6 +100,8 @@ async def aget_all_variants(
except Exception:
logger.exception("Failed to get remote variants")

if flags is not None:
flags = {k: v.with_source(VariantSource.REMOTE) for k, v in flags.items()}
return flags

async def aget_variant_value(
Expand Down Expand Up @@ -151,9 +159,15 @@ async def aget_variant(
distinct_id, EXPOSURE_EVENT, properties
)
)
except Exception:
except Exception as exc:
logger.exception("Failed to get remote variant for flag '%s'", flag_key)
return fallback_value
# SDK-83: attach the exception message so the OpenFeature wrapper
# can forward it as error_message. Without this the caller sees
# a bare GENERAL error and has to dig through logs to find out
# the backend rejected the request.
return fallback_value.as_fallback(
FallbackReason.backend_error(self._describe_backend_error(exc))
)
else:
return selected_variant

Expand Down Expand Up @@ -210,6 +224,8 @@ def get_all_variants(
except Exception:
logger.exception("Failed to get remote variants")

if flags is not None:
flags = {k: v.with_source(VariantSource.REMOTE) for k, v in flags.items()}
return flags

def get_variant_value(
Expand Down Expand Up @@ -265,9 +281,13 @@ def get_variant(
)
self._tracker(distinct_id, EXPOSURE_EVENT, properties)

except Exception:
except Exception as exc:
logger.exception("Failed to get remote variant for flag '%s'", flag_key)
return fallback_value
# SDK-83: attach the exception message so the OpenFeature wrapper
# can forward it as error_message.
return fallback_value.as_fallback(
FallbackReason.backend_error(self._describe_backend_error(exc))
)
else:
return selected_variant

Expand Down Expand Up @@ -355,20 +375,38 @@ def _handle_response(self, response: httpx.Response) -> dict[str, SelectedVarian
flags_response = RemoteFlagsResponse.model_validate(response.json())
return flags_response.flags

@staticmethod
def _describe_backend_error(exc: Exception) -> str:
"""Best-effort backend message for FallbackReason.backend_error.

For HTTP errors the response body usually contains the actionable
detail (e.g. "distinct_id must be provided in evalContext as a
string") — httpx's default str(exc) only carries the status line,
so reach into exc.response.text when available.
"""
if isinstance(exc, httpx.HTTPStatusError):
body = exc.response.text.strip() if exc.response is not None else ""
return f"HTTP {exc.response.status_code}: {body}" if body else str(exc)
Comment on lines +387 to +389

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security str(httpx.HTTPStatusError) includes the full request URL, which contains token (set as a query param by prepare_common_query_params) and the JSON-encoded context (including distinct_id). When the backend returns a 4xx/5xx with an empty body, body is falsy and this path exposes those values in the OpenFeature error_message — a new exposure vector introduced by this PR. Returning just the status code when body is empty avoids the leak.

Suggested change
if isinstance(exc, httpx.HTTPStatusError):
body = exc.response.text.strip() if exc.response is not None else ""
return f"HTTP {exc.response.status_code}: {body}" if body else str(exc)
if isinstance(exc, httpx.HTTPStatusError):
body = exc.response.text.strip() if exc.response is not None else ""
return f"HTTP {exc.response.status_code}: {body}" if body else f"HTTP {exc.response.status_code}"

return str(exc)

def _lookup_flag_in_response(
self,
flag_key: str,
flags: dict[str, SelectedVariant],
fallback_value: SelectedVariant,
) -> tuple[SelectedVariant, bool]:
if flag_key in flags:
return flags[flag_key], False
return flags[flag_key].with_source(VariantSource.REMOTE), False
logger.debug(
"Flag '%s' not found in remote response. Returning fallback, '%s'",
flag_key,
fallback_value,
)
return fallback_value, True
# The /flags endpoint only returns variants the user is enrolled in,
# so a missing key could mean the flag doesn't exist OR the user
# isn't in any rollout. The remote SDK can't tell them apart without
# server-side help — surface as FLAG_NOT_FOUND for now.
return fallback_value.as_fallback(FallbackReason.flag_not_found()), True

def shutdown(self):
self._sync_client.close()
Expand Down
40 changes: 40 additions & 0 deletions mixpanel/flags/test_local_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
SelectedVariant,
Variant,
VariantOverride,
VariantSource,
)

TEST_FLAG_KEY = "test_flag"
Expand Down Expand Up @@ -723,6 +724,45 @@ async def test_is_enabled_returns_true_for_true_variant_value(self):
result = self._flags.is_enabled(TEST_FLAG_KEY, USER_CONTEXT)
assert result is True

@respx.mock
async def test_get_variant_tags_match_as_local(self):
flag = create_test_flag(rollout_percentage=100.0)
await self.setup_flags([flag])
fallback = SelectedVariant(variant_value="fb")
result = self._flags.get_variant(TEST_FLAG_KEY, fallback, USER_CONTEXT)
assert result.variant_source == VariantSource.LOCAL
assert result.fallback_reason is None
assert result.variant_key is not None

@respx.mock
async def test_get_variant_tags_missing_flag(self):
await self.setup_flags([])
fallback = SelectedVariant(variant_value="fb")
result = self._flags.get_variant("missing", fallback, USER_CONTEXT)
assert result.variant_source == VariantSource.FALLBACK
assert result.fallback_reason.kind == "FLAG_NOT_FOUND"
assert result.fallback_reason.message is None
assert result.variant_value == "fb"

@respx.mock
async def test_get_variant_tags_missing_context(self):
flag = create_test_flag(context="distinct_id")
await self.setup_flags([flag])
fallback = SelectedVariant(variant_value="fb")
result = self._flags.get_variant(TEST_FLAG_KEY, fallback, {})
assert result.variant_source == VariantSource.FALLBACK
assert result.fallback_reason.kind == "MISSING_CONTEXT_KEY"
assert result.fallback_reason.message == "distinct_id"

@respx.mock
async def test_get_variant_tags_no_rollout_match(self):
flag = create_test_flag(rollout_percentage=0.0)
await self.setup_flags([flag])
fallback = SelectedVariant(variant_value="fb")
result = self._flags.get_variant(TEST_FLAG_KEY, fallback, USER_CONTEXT)
assert result.variant_source == VariantSource.FALLBACK
assert result.fallback_reason.kind == "NO_ROLLOUT_MATCH"

@respx.mock
async def test_get_variant_value_uses_most_recent_polled_flag(self):
polling_iterations = 0
Expand Down
38 changes: 35 additions & 3 deletions mixpanel/flags/test_remote_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
from mixpanel.credentials import ServiceAccountCredentials

from .remote_feature_flags import RemoteFeatureFlagsProvider
from .types import RemoteFlagsConfig, RemoteFlagsResponse, SelectedVariant
from .types import (
RemoteFlagsConfig,
RemoteFlagsResponse,
SelectedVariant,
VariantSource,
)

ENDPOINT = "https://api.mixpanel.com/flags"

Expand Down Expand Up @@ -154,7 +159,11 @@ async def test_aget_all_variants_returns_all_variants_from_api(self):

result = await self._flags.aget_all_variants({"distinct_id": "user123"})

assert result == variants
assert set(result.keys()) == {"flag1", "flag2"}
assert result["flag1"].variant_value == "value1"
assert result["flag2"].variant_value == "value2"
# Every returned variant must be tagged with variant_source=REMOTE.
assert all(v.variant_source == VariantSource.REMOTE for v in result.values())

@respx.mock
async def test_aget_all_variants_returns_none_on_network_error(self):
Expand Down Expand Up @@ -265,6 +274,26 @@ def test_get_variant_value_is_fallback_if_call_fails(self):
)
assert result == "control"

@respx.mock
def test_get_variant_tags_fallback_with_backend_message_on_http_error(self):
"""SDK-83: the backend's response message must propagate through
FallbackReason.message so the OpenFeature wrapper can forward it
as error_message instead of swallowing it into a bare GENERAL."""
respx.get(ENDPOINT).mock(
return_value=httpx.Response(
400, text="distinct_id must be provided in evalContext as a string"
)
)

fallback = SelectedVariant(variant_value="control")
result = self._flags.get_variant(
"test_flag", fallback, {"distinct_id": "user123"}, reportExposure=False
)

assert result.variant_source == VariantSource.FALLBACK
assert result.fallback_reason.kind == "BACKEND_ERROR"
assert "distinct_id must be provided" in result.fallback_reason.message

@respx.mock
def test_get_variant_value_is_fallback_if_bad_response_format(self):
respx.get(ENDPOINT).mock(return_value=httpx.Response(200, text="invalid json"))
Expand Down Expand Up @@ -365,7 +394,10 @@ def test_get_all_variants_returns_all_variants_from_api(self):

result = self._flags.get_all_variants({"distinct_id": "user123"})

assert result == variants
assert set(result.keys()) == {"flag1", "flag2"}
assert result["flag1"].variant_value == "value1"
assert result["flag2"].variant_value == "value2"
assert all(v.variant_source == VariantSource.REMOTE for v in result.values())

@respx.mock
def test_get_all_variants_returns_none_on_network_error(self):
Expand Down
85 changes: 84 additions & 1 deletion mixpanel/flags/types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Any, Literal, Optional

from pydantic import BaseModel, ConfigDict

Expand Down Expand Up @@ -63,13 +63,96 @@ class ExperimentationFlag(BaseModel):
hash_salt: Optional[str] = None


class VariantSource:
"""Where a SelectedVariant came from.

Set by the providers on every returned variant — coarse-grained
(local / remote / fallback). For the specific reason behind a fallback,
see FallbackReason.
Comment on lines +69 to +71
"""

LOCAL = "local"
REMOTE = "remote"
FALLBACK = "fallback"


class FallbackReason(BaseModel):
"""Why the SDK returned the developer fallback.

Only meaningful when SelectedVariant.variant_source == VariantSource.FALLBACK.

`kind` is the discriminator (PHP-aligned). `message` is set on reasons
that carry useful detail (BACKEND_ERROR with the backend's response body,
MISSING_CONTEXT_KEY with the missing attribute name); None otherwise.
The OpenFeature wrapper dispatches on kind and forwards message into
FlagResolutionDetails.error_message.
"""

model_config = ConfigDict(frozen=True)

kind: Literal[
"FLAG_NOT_FOUND",
"MISSING_CONTEXT_KEY",
"NO_ROLLOUT_MATCH",
"BACKEND_ERROR",
]
Comment on lines +93 to +98
message: Optional[str] = None

# Factory methods. Reasons without meaningful detail return a frozen
# singleton; reasons with detail allocate per call.
@classmethod
def flag_not_found(cls) -> "FallbackReason":
return _FLAG_NOT_FOUND

@classmethod
def no_rollout_match(cls) -> "FallbackReason":
return _NO_ROLLOUT_MATCH

@classmethod
def missing_context_key(cls, key: str) -> "FallbackReason":
# The whole point of MISSING_CONTEXT_KEY is telling the caller *which*
# attribute is absent; a nullable default would leak `message=None`
# into the OpenFeature wrapper's error_message and defeat the SDK-79
# richer-error-propagation goal.
return cls(kind="MISSING_CONTEXT_KEY", message=key)

@classmethod
def backend_error(cls, message: str) -> "FallbackReason":
return cls(kind="BACKEND_ERROR", message=message)


_FLAG_NOT_FOUND = FallbackReason(kind="FLAG_NOT_FOUND")
_NO_ROLLOUT_MATCH = FallbackReason(kind="NO_ROLLOUT_MATCH")


class SelectedVariant(BaseModel):
# variant_key can be None if being used as a fallback
variant_key: Optional[str] = None
variant_value: Any
experiment_id: Optional[str] = None
is_experiment_active: Optional[bool] = None
is_qa_tester: Optional[bool] = None
variant_source: Optional[str] = None
# None on success; set when variant_source == FALLBACK
fallback_reason: Optional[FallbackReason] = None

def with_source(self, source: str) -> "SelectedVariant":
"""Return a copy of this variant tagged with the given source.

Clears fallback_reason — use as_fallback() if returning a fallback.
"""
return self.model_copy(
update={"variant_source": source, "fallback_reason": None}
)

def as_fallback(self, reason: FallbackReason) -> "SelectedVariant":
"""Return a copy of this variant tagged as a fallback with the given reason."""
return self.model_copy(
update={
"variant_source": VariantSource.FALLBACK,
"fallback_reason": reason,
}
)


class ExperimentationFlags(BaseModel):
Expand Down
Loading
Loading