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.track import TrackingEventDetails
from openfeature.transaction_context import get_transaction_context
Expand Down Expand Up @@ -430,6 +430,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 thread
jonathannorris marked this conversation as resolved.

# Hooks need to be handled in different orders at different stages
# in the flag evaluation
Expand All @@ -451,7 +456,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 @@ -466,6 +471,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
) is True 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 @@ -612,13 +656,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 @@ -788,13 +840,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
25 changes: 24 additions & 1 deletion openfeature/provider/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,25 @@
from openfeature.hook import Hook
from openfeature.track import TrackingEventDetails

from ._protocols import InternalHookProvider
from .metadata import Metadata

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 @@ -263,3 +276,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,
)
38 changes: 38 additions & 0 deletions openfeature/provider/_protocols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

import typing

if typing.TYPE_CHECKING:
from openfeature.provider import ProviderStatus


@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: ...
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
) is True and isinstance(provider, InternalHookProvider):
return provider.get_status()
return self._provider_status.get(provider, ProviderStatus.NOT_READY)

def dispatch_event(
Expand Down
16 changes: 16 additions & 0 deletions openfeature/provider/multi_provider/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from ._provider import MultiProvider, ProviderEntry
from ._strategies import (
ComparisonStrategy,
EvaluationStrategy,
FirstMatchStrategy,
FirstSuccessfulStrategy,
)

__all__ = [
"ComparisonStrategy",
"EvaluationStrategy",
"FirstMatchStrategy",
"FirstSuccessfulStrategy",
"MultiProvider",
"ProviderEntry",
]
Loading
Loading