Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 70 additions & 10 deletions openfeature/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
before_hooks,
error_hooks,
)
from openfeature.provider import FeatureProvider, ProviderStatus
from openfeature.provider import FeatureProvider, InternalHookProvider, ProviderStatus
from openfeature.provider._registry import provider_registry
from openfeature.transaction_context import get_transaction_context

Expand Down Expand Up @@ -429,6 +429,11 @@ def _establish_hooks_and_provider(

client_metadata = self.get_metadata()
provider_metadata = provider.get_metadata()
provider_hooks = (
[]
if self._provider_uses_internal_hooks(provider)
else provider.get_provider_hooks()
)
Comment on lines +432 to +436

Choose a reason for hiding this comment

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

medium

The provider_hooks variable is assigned an empty list or the result of provider.get_provider_hooks(). It would be more readable to assign the result of provider.get_provider_hooks() directly to provider_hooks and then conditionally clear the list if _provider_uses_internal_hooks returns true. This simplifies the logic and makes it easier to understand the intent.

        provider_hooks = provider.get_provider_hooks()
        if self._provider_uses_internal_hooks(provider):
            provider_hooks = []


# Hooks need to be handled in different orders at different stages
# in the flag evaluation
Expand All @@ -450,7 +455,7 @@ def _establish_hooks_and_provider(
get_hooks(),
self.hooks,
evaluation_hooks,
provider.get_provider_hooks(),
provider_hooks,
)
]
# after, error, finally: Provider, Invocation, Client, API
Expand All @@ -465,6 +470,45 @@ def _establish_hooks_and_provider(
merged_eval_context,
)

def _as_internal_hook_provider(
self, provider: FeatureProvider
) -> InternalHookProvider | None:
"""Return the provider as InternalHookProvider if it opts in, else None."""
if getattr(provider, "_is_internal_hook_provider", False) and isinstance(
provider, InternalHookProvider
):
return provider
return None

def _provider_uses_internal_hooks(self, provider: FeatureProvider) -> bool:
ihp = self._as_internal_hook_provider(provider)
return ihp is not None and ihp.uses_internal_provider_hooks()

def _set_internal_provider_hook_runtime(
self,
provider: FeatureProvider,
flag_type: FlagType,
hook_hints: HookHints,
) -> object | None:
ihp = self._as_internal_hook_provider(provider)
if ihp is None or not ihp.uses_internal_provider_hooks():
return None
result: object | None = ihp.set_internal_provider_hook_runtime(
flag_type=flag_type,
client_metadata=self.get_metadata(),
hook_hints=hook_hints,
)
return result

def _reset_internal_provider_hook_runtime(
self, provider: FeatureProvider, runtime_token: object | None
) -> None:
if runtime_token is None:
return
ihp = self._as_internal_hook_provider(provider)
if ihp is not None:
ihp.reset_internal_provider_hook_runtime(runtime_token)

def _assert_provider_status(
self,
) -> OpenFeatureError | None:
Expand Down Expand Up @@ -611,13 +655,21 @@ async def evaluate_flag_details_async(
merged_eval_context,
)

flag_evaluation = await self._create_provider_evaluation_async(
runtime_token = self._set_internal_provider_hook_runtime(
provider,
flag_type,
flag_key,
default_value,
merged_context,
hook_hints,
)
try:
flag_evaluation = await self._create_provider_evaluation_async(
provider,
flag_type,
flag_key,
default_value,
merged_context,
)
finally:
self._reset_internal_provider_hook_runtime(provider, runtime_token)
if err := flag_evaluation.get_exception():
error_hooks(
flag_type, err, reversed_merged_hooks_and_context, hook_hints
Expand Down Expand Up @@ -787,13 +839,21 @@ def evaluate_flag_details(
merged_eval_context,
)

flag_evaluation = self._create_provider_evaluation(
runtime_token = self._set_internal_provider_hook_runtime(
provider,
flag_type,
flag_key,
default_value,
merged_context,
hook_hints,
)
try:
flag_evaluation = self._create_provider_evaluation(
provider,
flag_type,
flag_key,
default_value,
merged_context,
)
finally:
self._reset_internal_provider_hook_runtime(provider, runtime_token)
if err := flag_evaluation.get_exception():
error_hooks(
flag_type, err, reversed_merged_hooks_and_context, hook_hints
Expand Down
56 changes: 55 additions & 1 deletion openfeature/provider/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,19 @@
if typing.TYPE_CHECKING:
from openfeature.flag_evaluation import FlagValueType

__all__ = ["AbstractProvider", "FeatureProvider", "Metadata", "ProviderStatus"]
__all__ = [
"AbstractProvider",
"ComparisonStrategy",
"EvaluationStrategy",
"FeatureProvider",
"FirstMatchStrategy",
"FirstSuccessfulStrategy",
"InternalHookProvider",
"Metadata",
"MultiProvider",
"ProviderEntry",
"ProviderStatus",
]


class ProviderStatus(Enum):
Expand Down Expand Up @@ -117,6 +129,38 @@ async def resolve_object_details_async(
]: ...


@typing.runtime_checkable
class InternalHookProvider(typing.Protocol):
"""Protocol for providers that manage their own provider hook execution.

Providers implementing this protocol (e.g. MultiProvider) handle provider
hook lifecycle internally. The client will skip its own provider hook
invocations and instead delegate to the provider via the set/reset methods.

The registry will also use get_status() from this protocol instead of its
own internal status tracking for providers that implement it.

Implementations must set ``_is_internal_hook_provider = True`` as a class
attribute. This marker is checked alongside ``isinstance`` to avoid false
positives from duck-typed objects (e.g. ``Mock``).
"""

_is_internal_hook_provider: typing.ClassVar[bool]

def uses_internal_provider_hooks(self) -> bool: ...

def set_internal_provider_hook_runtime(
self,
flag_type: typing.Any,
client_metadata: typing.Any,
hook_hints: typing.Any,
) -> typing.Any: ...

def reset_internal_provider_hook_runtime(self, token: typing.Any) -> None: ...

def get_status(self) -> ProviderStatus: ...


class AbstractProvider(FeatureProvider):
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
# this makes sure to invoke the parent of `FeatureProvider` -> `object`
Expand Down Expand Up @@ -247,3 +291,13 @@ def emit_provider_stale(self, details: ProviderEventDetails) -> None:
def emit(self, event: ProviderEvent, details: ProviderEventDetails) -> None:
if hasattr(self, "_on_emit"):
self._on_emit(self, event, details)


from .multi_provider import ( # noqa: E402
ComparisonStrategy,
EvaluationStrategy,
FirstMatchStrategy,
FirstSuccessfulStrategy,
MultiProvider,
ProviderEntry,
)
40 changes: 28 additions & 12 deletions openfeature/provider/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
ProviderEventDetails,
)
from openfeature.exception import ErrorCode, GeneralError, OpenFeatureError
from openfeature.provider import FeatureProvider, ProviderStatus
from openfeature.provider import FeatureProvider, InternalHookProvider, ProviderStatus
from openfeature.provider.no_op_provider import NoOpProvider


Expand Down Expand Up @@ -80,23 +80,30 @@ def _initialize_provider(self, provider: FeatureProvider) -> None:
try:
if hasattr(provider, "initialize"):
provider.initialize(self._get_evaluation_context())
self.dispatch_event(
provider, ProviderEvent.PROVIDER_READY, ProviderEventDetails()
)
# InternalHookProvider (e.g. MultiProvider) emits its own events
# during initialize(), so only dispatch PROVIDER_READY if the
# provider hasn't already transitioned away from NOT_READY.
if self.get_provider_status(provider) == ProviderStatus.NOT_READY:
self.dispatch_event(
provider, ProviderEvent.PROVIDER_READY, ProviderEventDetails()
)
except Exception as err:
error_code = (
err.error_code
if isinstance(err, OpenFeatureError)
else ErrorCode.GENERAL
)
self.dispatch_event(
provider,
ProviderEvent.PROVIDER_ERROR,
ProviderEventDetails(
message=f"Provider initialization failed: {err}",
error_code=error_code,
),
)
# Same guard: skip if the provider already emitted its own error
# event and transitioned out of NOT_READY.
if self.get_provider_status(provider) == ProviderStatus.NOT_READY:
self.dispatch_event(
provider,
ProviderEvent.PROVIDER_ERROR,
ProviderEventDetails(
message=f"Provider initialization failed: {err}",
error_code=error_code,
),
)

def _shutdown_provider(self, provider: FeatureProvider) -> None:
try:
Expand All @@ -115,6 +122,15 @@ def _shutdown_provider(self, provider: FeatureProvider) -> None:
provider.detach()

def get_provider_status(self, provider: FeatureProvider) -> ProviderStatus:
# Only InternalHookProvider implementations (e.g. MultiProvider) manage
# their own status. For all other providers, use the registry's tracking.
# We check _is_internal_hook_provider (a concrete class attribute) in
# addition to isinstance, because runtime_checkable Protocols match any
# object that has the right method names — including Mock objects.
if getattr(provider, "_is_internal_hook_provider", False) and isinstance(
provider, InternalHookProvider
):
return provider.get_status()
return self._provider_status.get(provider, ProviderStatus.NOT_READY)

def dispatch_event(
Expand Down
Loading
Loading