diff --git a/mixpanel/flags/local_feature_flags.py b/mixpanel/flags/local_feature_flags.py index e180943..8465e79 100644 --- a/mixpanel/flags/local_feature_flags.py +++ b/mixpanel/flags/local_feature_flags.py @@ -15,9 +15,11 @@ from .types import ( ExperimentationFlag, ExperimentationFlags, + FallbackReason, LocalFlagsConfig, Rollout, SelectedVariant, + VariantSource, ) from .utils import ( EXPOSURE_EVENT, @@ -228,7 +230,7 @@ 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( @@ -236,7 +238,9 @@ def get_variant( 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 @@ -257,7 +261,7 @@ 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", @@ -265,7 +269,7 @@ def get_variant( 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] diff --git a/mixpanel/flags/remote_feature_flags.py b/mixpanel/flags/remote_feature_flags.py index c47a20c..caba858 100644 --- a/mixpanel/flags/remote_feature_flags.py +++ b/mixpanel/flags/remote_feature_flags.py @@ -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, @@ -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( @@ -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 @@ -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( @@ -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 @@ -355,6 +375,20 @@ 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) + return str(exc) + def _lookup_flag_in_response( self, flag_key: str, @@ -362,13 +396,17 @@ def _lookup_flag_in_response( 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() diff --git a/mixpanel/flags/test_local_feature_flags.py b/mixpanel/flags/test_local_feature_flags.py index b39caa3..9839b1b 100644 --- a/mixpanel/flags/test_local_feature_flags.py +++ b/mixpanel/flags/test_local_feature_flags.py @@ -23,6 +23,7 @@ SelectedVariant, Variant, VariantOverride, + VariantSource, ) TEST_FLAG_KEY = "test_flag" @@ -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 diff --git a/mixpanel/flags/test_remote_feature_flags.py b/mixpanel/flags/test_remote_feature_flags.py index ec5f16d..b92d06e 100644 --- a/mixpanel/flags/test_remote_feature_flags.py +++ b/mixpanel/flags/test_remote_feature_flags.py @@ -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" @@ -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): @@ -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")) @@ -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): diff --git a/mixpanel/flags/types.py b/mixpanel/flags/types.py index 8325439..4e02d8c 100644 --- a/mixpanel/flags/types.py +++ b/mixpanel/flags/types.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Literal, Optional from pydantic import BaseModel, ConfigDict @@ -63,6 +63,68 @@ 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. + """ + + 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", + ] + 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 @@ -70,6 +132,27 @@ class SelectedVariant(BaseModel): 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): diff --git a/openfeature-provider/src/mixpanel_openfeature/provider.py b/openfeature-provider/src/mixpanel_openfeature/provider.py index ec16d78..6ec9cb3 100644 --- a/openfeature-provider/src/mixpanel_openfeature/provider.py +++ b/openfeature-provider/src/mixpanel_openfeature/provider.py @@ -2,16 +2,24 @@ import math import typing -from collections.abc import Mapping, Sequence -from typing import Optional, Union +from typing import Union -from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode from openfeature.flag_evaluation import FlagResolutionDetails, Reason from openfeature.provider import AbstractProvider, Metadata from mixpanel import Mixpanel -from mixpanel.flags.types import LocalFlagsConfig, RemoteFlagsConfig, SelectedVariant +from mixpanel.flags.types import ( + FallbackReason, + LocalFlagsConfig, + RemoteFlagsConfig, + SelectedVariant, +) + +if typing.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from openfeature.evaluation_context import EvaluationContext FlagValueType = Union[bool, str, int, float, list, dict, None] @@ -22,7 +30,7 @@ class MixpanelProvider(AbstractProvider): def __init__( self, flags_provider: typing.Any, - mixpanel_instance: Optional[Mixpanel] = None, + mixpanel_instance: Mixpanel | None = None, ) -> None: super().__init__() self._flags_provider = flags_provider @@ -56,7 +64,7 @@ def from_remote_config( return cls(remote_flags, mixpanel_instance=mp) @property - def mixpanel(self) -> Optional[Mixpanel]: + def mixpanel(self) -> Mixpanel | None: """The Mixpanel instance used by this provider, if created via a class method.""" return self._mixpanel @@ -70,7 +78,7 @@ def resolve_boolean_details( self, flag_key: str, default_value: bool, - evaluation_context: typing.Optional[EvaluationContext] = None, + evaluation_context: EvaluationContext | None = None, ) -> FlagResolutionDetails[bool]: return self._resolve(flag_key, default_value, bool, evaluation_context) @@ -78,7 +86,7 @@ def resolve_string_details( self, flag_key: str, default_value: str, - evaluation_context: typing.Optional[EvaluationContext] = None, + evaluation_context: EvaluationContext | None = None, ) -> FlagResolutionDetails[str]: return self._resolve(flag_key, default_value, str, evaluation_context) @@ -86,7 +94,7 @@ def resolve_integer_details( self, flag_key: str, default_value: int, - evaluation_context: typing.Optional[EvaluationContext] = None, + evaluation_context: EvaluationContext | None = None, ) -> FlagResolutionDetails[int]: return self._resolve(flag_key, default_value, int, evaluation_context) @@ -94,17 +102,17 @@ def resolve_float_details( self, flag_key: str, default_value: float, - evaluation_context: typing.Optional[EvaluationContext] = None, + evaluation_context: EvaluationContext | None = None, ) -> FlagResolutionDetails[float]: return self._resolve(flag_key, default_value, float, evaluation_context) def resolve_object_details( self, flag_key: str, - default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]], - evaluation_context: typing.Optional[EvaluationContext] = None, + default_value: Sequence[FlagValueType] | Mapping[str, FlagValueType], + evaluation_context: EvaluationContext | None = None, ) -> FlagResolutionDetails[ - Union[Sequence[FlagValueType], Mapping[str, FlagValueType]] + Sequence[FlagValueType] | Mapping[str, FlagValueType] ]: return self._resolve(flag_key, default_value, None, evaluation_context) @@ -120,7 +128,7 @@ def _unwrap_value(value: typing.Any) -> typing.Any: @staticmethod def _build_user_context( - evaluation_context: typing.Optional[EvaluationContext], + evaluation_context: EvaluationContext | None, ) -> dict: user_context: dict = {} if evaluation_context is not None: @@ -131,12 +139,12 @@ def _build_user_context( user_context["targetingKey"] = evaluation_context.targeting_key return user_context - def _resolve( + def _resolve( # noqa: C901 — type-coercion branches are intentional self, flag_key: str, default_value: typing.Any, - expected_type: typing.Optional[type], - evaluation_context: typing.Optional[EvaluationContext] = None, + expected_type: type | None, + evaluation_context: EvaluationContext | None = None, ) -> FlagResolutionDetails: if not self._are_flags_ready(): return FlagResolutionDetails( @@ -149,20 +157,30 @@ def _resolve( user_context = self._build_user_context(evaluation_context) try: result = self._flags_provider.get_variant(flag_key, fallback, user_context) - except Exception: + except Exception as exc: return FlagResolutionDetails( value=default_value, error_code=ErrorCode.GENERAL, + error_message=str(exc), reason=Reason.ERROR, ) - if result is fallback: + # Defensive: a custom flags provider written against the pre-SDK-79 + # contract may return the fallback object unchanged (no .as_fallback + # tag). Treat that as FLAG_NOT_FOUND rather than a successful match. + if self._is_untagged_fallback(result, fallback): return FlagResolutionDetails( value=default_value, error_code=ErrorCode.FLAG_NOT_FOUND, reason=Reason.DEFAULT, ) + fallback_details = self._fallback_details( + result.fallback_reason, default_value + ) + if fallback_details is not None: + return fallback_details + value = result.variant_value variant_key = result.variant_key @@ -210,6 +228,55 @@ def _resolve( value=value, variant=variant_key, reason=Reason.TARGETING_MATCH ) + @staticmethod + def _is_untagged_fallback( + result: SelectedVariant, fallback: SelectedVariant + ) -> bool: + return result is fallback and result.fallback_reason is None + + @staticmethod + def _fallback_details( + fallback_reason: FallbackReason | None, default_value: typing.Any + ) -> FlagResolutionDetails | None: + """Map a fallback reason to its OpenFeature response, or None if not a fallback. + + variant_source distinguishes local / remote / fallback. When fallback, + fallback_reason carries the discriminating kind (PHP-aligned) and an + optional message (BACKEND_ERROR's response body, MISSING_CONTEXT_KEY's + missing attribute) so we map each to the spec-correct OpenFeature + response and forward the message as error_message. + """ + if fallback_reason is None: + return None + # Flag exists, user just didn't match any rollout — per the + # OpenFeature spec this is `reason: DEFAULT` with no error. + if fallback_reason.kind == "NO_ROLLOUT_MATCH": + return FlagResolutionDetails(value=default_value, reason=Reason.DEFAULT) + error_mapping = { + "FLAG_NOT_FOUND": (ErrorCode.FLAG_NOT_FOUND, Reason.DEFAULT), + "MISSING_CONTEXT_KEY": (ErrorCode.TARGETING_KEY_MISSING, Reason.ERROR), + "BACKEND_ERROR": (ErrorCode.GENERAL, Reason.ERROR), + } + if fallback_reason.kind in error_mapping: + error_code, reason = error_mapping[fallback_reason.kind] + return FlagResolutionDetails( + value=default_value, + error_code=error_code, + error_message=fallback_reason.message, + reason=reason, + ) + # Exhaustive: every FallbackReason.Kind Literal is handled above. If a + # new kind is added to the enum without also being wired into the + # mapping (or NO_ROLLOUT_MATCH branch above), fall back to GENERAL + # rather than returning None — which _resolve would silently + # misinterpret as a successful targeting match. + return FlagResolutionDetails( + value=default_value, + error_code=ErrorCode.GENERAL, + error_message=f"Unrecognized fallback_reason.kind: {fallback_reason.kind}", + reason=Reason.ERROR, + ) + def _are_flags_ready(self) -> bool: if hasattr(self._flags_provider, "are_flags_ready"): return self._flags_provider.are_flags_ready() diff --git a/openfeature-provider/tests/test_provider.py b/openfeature-provider/tests/test_provider.py index 723f9ce..6a5809c 100644 --- a/openfeature-provider/tests/test_provider.py +++ b/openfeature-provider/tests/test_provider.py @@ -5,7 +5,7 @@ from openfeature.exception import ErrorCode from openfeature.flag_evaluation import Reason -from mixpanel.flags.types import SelectedVariant +from mixpanel.flags.types import FallbackReason, SelectedVariant, VariantSource from mixpanel_openfeature import MixpanelProvider @@ -22,17 +22,28 @@ def provider(mock_flags): def setup_flag(mock_flags, flag_key, value, variant_key="variant-key"): - """Configure mock to return a SelectedVariant with the given value.""" + """Configure mock to return a successfully-evaluated SelectedVariant.""" mock_flags.get_variant.side_effect = lambda key, fallback, ctx: ( - SelectedVariant(variant_key=variant_key, variant_value=value) + SelectedVariant( + variant_key=variant_key, + variant_value=value, + variant_source=VariantSource.LOCAL, + ) if key == flag_key - else fallback + else fallback.as_fallback(FallbackReason.flag_not_found()) + ) + + +def setup_fallback(mock_flags, reason): + """Configure mock to always return the caller's fallback tagged with `reason`.""" + mock_flags.get_variant.side_effect = ( + lambda key, fallback, ctx: fallback.as_fallback(reason) ) def setup_flag_not_found(mock_flags, flag_key): - """Configure mock to return the fallback (identity check triggers FLAG_NOT_FOUND).""" - mock_flags.get_variant.side_effect = lambda key, fallback, ctx: fallback + """Configure mock for the genuinely-missing-flag path.""" + setup_fallback(mock_flags, FallbackReason.flag_not_found()) # --- Metadata --- @@ -152,6 +163,20 @@ def test_flag_not_found_boolean(provider, mock_flags): assert result.reason == Reason.DEFAULT +def test_bare_fallback_treated_as_flag_not_found(provider, mock_flags): + """A custom flags provider written against the pre-SDK-79 contract may + return the fallback object unchanged (no .as_fallback tag). Treat that + as FLAG_NOT_FOUND rather than a successful targeting match.""" + # Return the fallback object as-is with no fallback_reason set. + mock_flags.get_variant.side_effect = ( + lambda _key, fallback, _ctx, **_kwargs: fallback + ) + result = provider.resolve_string_details("any-flag", "default") + assert result.value == "default" + assert result.error_code == ErrorCode.FLAG_NOT_FOUND + assert result.reason == Reason.DEFAULT + + def test_flag_not_found_string(provider, mock_flags): setup_flag_not_found(mock_flags, "missing-flag") result = provider.resolve_string_details("missing-flag", "fallback") @@ -298,7 +323,7 @@ def test_remote_provider_always_ready(): remote_flags = MagicMock(spec=[]) # empty spec = no attributes remote_flags.get_variant = MagicMock( side_effect=lambda key, fallback, ctx: SelectedVariant( - variant_key="v1", variant_value=True + variant_key="v1", variant_value=True, variant_source=VariantSource.REMOTE ) ) provider = MixpanelProvider(remote_flags) @@ -307,6 +332,64 @@ def test_remote_provider_always_ready(): assert result.reason == Reason.TARGETING_MATCH +# --- No rollout matched (flag exists, no rollout matched) --- + + +def test_no_rollout_match_returns_default_reason_without_error(provider, mock_flags): + setup_fallback(mock_flags, FallbackReason.no_rollout_match()) + result = provider.resolve_boolean_details("flag", True) + assert result.value is True + assert result.reason == Reason.DEFAULT + assert result.error_code is None + + +def test_no_rollout_match_for_string(provider, mock_flags): + setup_fallback(mock_flags, FallbackReason.no_rollout_match()) + result = provider.resolve_string_details("flag", "default") + assert result.value == "default" + assert result.reason == Reason.DEFAULT + assert result.error_code is None + + +# --- Missing context key --- + + +def test_missing_context_key_returns_targeting_key_missing(provider, mock_flags): + setup_fallback(mock_flags, FallbackReason.missing_context_key("distinct_id")) + result = provider.resolve_boolean_details("flag", False) + assert result.value is False + assert result.error_code == ErrorCode.TARGETING_KEY_MISSING + assert result.reason == Reason.ERROR + + +def test_missing_context_key_forwards_missing_attribute_as_error_message( + provider, mock_flags +): + setup_fallback(mock_flags, FallbackReason.missing_context_key("distinct_id")) + result = provider.resolve_string_details("flag", "default") + assert result.value == "default" + assert result.error_code == ErrorCode.TARGETING_KEY_MISSING + assert result.error_message == "distinct_id" + assert result.reason == Reason.ERROR + + +# --- Backend error (SDK-83: forwards the backend's response message) --- + + +def test_backend_error_maps_to_general_and_forwards_message(provider, mock_flags): + setup_fallback( + mock_flags, + FallbackReason.backend_error( + "HTTP 400: distinct_id must be provided in evalContext as a string" + ), + ) + result = provider.resolve_string_details("flag", "default") + assert result.value == "default" + assert result.error_code == ErrorCode.GENERAL + assert result.reason == Reason.ERROR + assert "distinct_id must be provided" in result.error_message + + # --- Lifecycle ---