Skip to content

Commit e1145f6

Browse files
fix: address multi-provider review issues
- Fix ContextVar propagation to ThreadPoolExecutor workers (Python <3.12) - Fix _refresh_aggregate_status dropping events during partial init failure - Add shouldEvaluateThisProvider check to skip NOT_READY/FATAL providers - Fix ComparisonStrategy to return first provider result on no-mismatch - Add InternalHookProvider protocol replacing fragile duck-typing - Scope get_status override in registry to InternalHookProvider only - Rename camelCase instance variables to snake_case Signed-off-by: Jonathan Norris <jonathan.norris@dynatrace.com>
1 parent 1c521a5 commit e1145f6

File tree

4 files changed

+137
-62
lines changed

4 files changed

+137
-62
lines changed

openfeature/client.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
before_hooks,
3131
error_hooks,
3232
)
33-
from openfeature.provider import FeatureProvider, ProviderStatus
33+
from openfeature.provider import FeatureProvider, InternalHookProvider, ProviderStatus
3434
from openfeature.provider._registry import provider_registry
3535
from openfeature.transaction_context import get_transaction_context
3636

@@ -471,21 +471,22 @@ def _establish_hooks_and_provider(
471471
)
472472

473473
def _provider_uses_internal_hooks(self, provider: FeatureProvider) -> bool:
474-
uses_internal_hooks = getattr(provider, "uses_internal_provider_hooks", None)
475-
return bool(callable(uses_internal_hooks) and uses_internal_hooks())
474+
return (
475+
isinstance(provider, InternalHookProvider)
476+
and provider.uses_internal_provider_hooks()
477+
)
476478

477479
def _set_internal_provider_hook_runtime(
478480
self,
479481
provider: FeatureProvider,
480482
flag_type: FlagType,
481483
hook_hints: HookHints,
482484
) -> object | None:
483-
if not self._provider_uses_internal_hooks(provider):
485+
if not isinstance(provider, InternalHookProvider):
484486
return None
485-
set_hook_runtime = getattr(provider, "set_internal_provider_hook_runtime", None)
486-
if not callable(set_hook_runtime):
487+
if not provider.uses_internal_provider_hooks():
487488
return None
488-
return set_hook_runtime(
489+
return provider.set_internal_provider_hook_runtime(
489490
flag_type=flag_type,
490491
client_metadata=self.get_metadata(),
491492
hook_hints=hook_hints,
@@ -496,9 +497,8 @@ def _reset_internal_provider_hook_runtime(
496497
) -> None:
497498
if runtime_token is None:
498499
return
499-
reset_hook_runtime = getattr(provider, "reset_internal_provider_hook_runtime", None)
500-
if callable(reset_hook_runtime):
501-
reset_hook_runtime(runtime_token)
500+
if isinstance(provider, InternalHookProvider):
501+
provider.reset_internal_provider_hook_runtime(runtime_token)
502502

503503
def _assert_provider_status(
504504
self,

openfeature/provider/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"FeatureProvider",
2323
"FirstMatchStrategy",
2424
"FirstSuccessfulStrategy",
25+
"InternalHookProvider",
2526
"Metadata",
2627
"MultiProvider",
2728
"ProviderEntry",
@@ -128,6 +129,32 @@ async def resolve_object_details_async(
128129
]: ...
129130

130131

132+
@typing.runtime_checkable
133+
class InternalHookProvider(typing.Protocol):
134+
"""Protocol for providers that manage their own provider hook execution.
135+
136+
Providers implementing this protocol (e.g. MultiProvider) handle provider
137+
hook lifecycle internally. The client will skip its own provider hook
138+
invocations and instead delegate to the provider via the set/reset methods.
139+
140+
The registry will also use get_status() from this protocol instead of its
141+
own internal status tracking for providers that implement it.
142+
"""
143+
144+
def uses_internal_provider_hooks(self) -> bool: ...
145+
146+
def set_internal_provider_hook_runtime(
147+
self,
148+
flag_type: typing.Any,
149+
client_metadata: typing.Any,
150+
hook_hints: typing.Any,
151+
) -> typing.Any: ...
152+
153+
def reset_internal_provider_hook_runtime(self, token: typing.Any) -> None: ...
154+
155+
def get_status(self) -> ProviderStatus: ...
156+
157+
131158
class AbstractProvider(FeatureProvider):
132159
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
133160
# this makes sure to invoke the parent of `FeatureProvider` -> `object`

openfeature/provider/_registry.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
ProviderEventDetails,
66
)
77
from openfeature.exception import ErrorCode, GeneralError, OpenFeatureError
8-
from openfeature.provider import FeatureProvider, ProviderStatus
8+
from openfeature.provider import FeatureProvider, InternalHookProvider, ProviderStatus
99
from openfeature.provider.no_op_provider import NoOpProvider
1010

1111

@@ -80,6 +80,9 @@ def _initialize_provider(self, provider: FeatureProvider) -> None:
8080
try:
8181
if hasattr(provider, "initialize"):
8282
provider.initialize(self._get_evaluation_context())
83+
# InternalHookProvider (e.g. MultiProvider) emits its own events
84+
# during initialize(), so only dispatch PROVIDER_READY if the
85+
# provider hasn't already transitioned away from NOT_READY.
8386
if self.get_provider_status(provider) == ProviderStatus.NOT_READY:
8487
self.dispatch_event(
8588
provider, ProviderEvent.PROVIDER_READY, ProviderEventDetails()
@@ -90,6 +93,8 @@ def _initialize_provider(self, provider: FeatureProvider) -> None:
9093
if isinstance(err, OpenFeatureError)
9194
else ErrorCode.GENERAL
9295
)
96+
# Same guard: skip if the provider already emitted its own error
97+
# event and transitioned out of NOT_READY.
9398
if self.get_provider_status(provider) == ProviderStatus.NOT_READY:
9499
self.dispatch_event(
95100
provider,
@@ -117,11 +122,10 @@ def _shutdown_provider(self, provider: FeatureProvider) -> None:
117122
provider.detach()
118123

119124
def get_provider_status(self, provider: FeatureProvider) -> ProviderStatus:
120-
provider_status_getter = getattr(provider, "get_status", None)
121-
if callable(provider_status_getter):
122-
status = provider_status_getter()
123-
if isinstance(status, ProviderStatus):
124-
return status
125+
# Only InternalHookProvider implementations (e.g. MultiProvider) manage
126+
# their own status. For all other providers, use the registry's tracking.
127+
if isinstance(provider, InternalHookProvider):
128+
return provider.get_status()
125129
return self._provider_status.get(provider, ProviderStatus.NOT_READY)
126130

127131
def dispatch_event(

0 commit comments

Comments
 (0)