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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ api.set_provider(NoOpProvider())
open_feature_client = api.get_client()
```

`set_provider()` is non-blocking: it registers the provider immediately and runs initialization in a background thread.
Flag evaluations during the initialization window return the default value with a `PROVIDER_NOT_READY` error code.
Use `set_provider_and_wait()` if you need to ensure the provider is ready before proceeding:

```python
# blocks until the provider is initialized (or raises on failure)
api.set_provider_and_wait(NoOpProvider())
```

In some situations, it may be beneficial to register multiple providers in the same application.
This is possible using [domains](#domains), which is covered in more detail below.

Expand Down
8 changes: 8 additions & 0 deletions openfeature/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"remove_handler",
"set_evaluation_context",
"set_provider",
"set_provider_and_wait",
"set_transaction_context",
"set_transaction_context_propagator",
"shutdown",
Expand All @@ -52,6 +53,13 @@ def set_provider(provider: FeatureProvider, domain: str | None = None) -> None:
provider_registry.set_provider(domain, provider)


def set_provider_and_wait(provider: FeatureProvider, domain: str | None = None) -> None:
if domain is None:
provider_registry.set_default_provider(provider, wait_for_init=True)
else:
provider_registry.set_provider(domain, provider, wait_for_init=True)


def clear_providers() -> None:
provider_registry.clear_providers()
_event_support.clear()
Expand Down
34 changes: 29 additions & 5 deletions openfeature/provider/_registry.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import threading

from openfeature._event_support import run_handlers_for_provider
from openfeature.evaluation_context import EvaluationContext, get_evaluation_context
from openfeature.event import (
Expand All @@ -21,7 +23,9 @@ def __init__(self) -> None:
self._default_provider: ProviderStatus.READY,
}

def set_provider(self, domain: str, provider: FeatureProvider) -> None:
def set_provider(
self, domain: str, provider: FeatureProvider, wait_for_init: bool = False
) -> None:
if provider is None:
raise GeneralError(error_message="No provider")
if domain is None:
Expand All @@ -36,15 +40,17 @@ def set_provider(self, domain: str, provider: FeatureProvider) -> None:
):
self._shutdown_provider(old_provider)
if provider != self._default_provider and provider not in providers.values():
self._initialize_provider(provider)
self._initialize_provider(provider, wait_for_init=wait_for_init)
Comment thread
jonathannorris marked this conversation as resolved.
providers[domain] = provider

def get_provider(self, domain: str | None) -> FeatureProvider:
if domain is None:
return self._default_provider
return self._providers.get(domain, self._default_provider)

def set_default_provider(self, provider: FeatureProvider) -> None:
def set_default_provider(
self, provider: FeatureProvider, wait_for_init: bool = False
) -> None:
if provider is None:
raise GeneralError(error_message="No provider")
if (
Expand All @@ -55,7 +61,7 @@ def set_default_provider(self, provider: FeatureProvider) -> None:
self._default_provider = provider

if self._default_provider not in self._providers.values():
self._initialize_provider(provider)
self._initialize_provider(provider, wait_for_init=wait_for_init)

def get_default_provider(self) -> FeatureProvider:
return self._default_provider
Expand All @@ -75,8 +81,24 @@ def shutdown(self) -> None:
def _get_evaluation_context(self) -> EvaluationContext:
return get_evaluation_context()

def _initialize_provider(self, provider: FeatureProvider) -> None:
def _initialize_provider(
self, provider: FeatureProvider, wait_for_init: bool = False
) -> None:
provider.attach(self.dispatch_event)
if wait_for_init:
self._run_initialize(provider, raise_on_error=True)
else:
thread = threading.Thread(
target=self._run_initialize,
args=(provider,),
kwargs={"raise_on_error": False},
daemon=True,
)
thread.start()
Comment thread
jonathannorris marked this conversation as resolved.

def _run_initialize(
self, provider: FeatureProvider, raise_on_error: bool = False
) -> None:
try:
if hasattr(provider, "initialize"):
provider.initialize(self._get_evaluation_context())
Expand All @@ -97,6 +119,8 @@ def _initialize_provider(self, provider: FeatureProvider) -> None:
error_code=error_code,
),
)
if raise_on_error:
raise

def _shutdown_provider(self, provider: FeatureProvider) -> None:
try:
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ def clear_providers():

@pytest.fixture()
def no_op_provider_client():
api.set_provider(NoOpProvider())
api.set_provider_and_wait(NoOpProvider())
return api.get_client()
4 changes: 2 additions & 2 deletions tests/features/steps/metadata_steps.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from behave import given, then

from openfeature.api import get_client, set_provider
from openfeature.api import get_client, set_provider_and_wait
from openfeature.provider.in_memory_provider import InMemoryProvider
from tests.features.data import IN_MEMORY_FLAGS


@given("a stable provider")
def step_impl_stable_provider(context):
set_provider(InMemoryProvider(IN_MEMORY_FLAGS))
set_provider_and_wait(InMemoryProvider(IN_MEMORY_FLAGS))
context.client = get_client()


Expand Down
6 changes: 3 additions & 3 deletions tests/features/steps/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from behave import given, then, when

from openfeature.api import get_client, set_provider
from openfeature.api import get_client, set_provider_and_wait
from openfeature.client import OpenFeatureClient
from openfeature.evaluation_context import EvaluationContext
from openfeature.exception import ErrorCode
Expand All @@ -28,13 +28,13 @@ def step_impl_resolved_should_be(context, flag_type, key, expected_reason):

@given("a provider is registered with cache disabled")
def step_impl_provider_without_cache(context):
set_provider(InMemoryProvider(IN_MEMORY_FLAGS))
set_provider_and_wait(InMemoryProvider(IN_MEMORY_FLAGS))
context.client = get_client()


@given("a provider is registered")
def step_impl_provider(context):
set_provider(InMemoryProvider(IN_MEMORY_FLAGS))
set_provider_and_wait(InMemoryProvider(IN_MEMORY_FLAGS))
context.client = get_client()


Expand Down
75 changes: 63 additions & 12 deletions tests/provider/test_registry.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import threading
from unittest.mock import Mock

import pytest

from openfeature.exception import GeneralError
from openfeature.exception import GeneralError, ProviderFatalError
from openfeature.provider import ProviderStatus
from openfeature.provider._registry import ProviderRegistry
from openfeature.provider.no_op_provider import NoOpProvider
Expand Down Expand Up @@ -67,8 +68,8 @@ def test_registering_provider_for_first_time_initializes_it():
registry = ProviderRegistry()
provider = Mock()

registry.set_provider("domain1", provider)
registry.set_provider("domain2", provider)
registry.set_provider("domain1", provider, wait_for_init=True)
registry.set_provider("domain2", provider, wait_for_init=True)

provider.initialize.assert_called_once()

Expand Down Expand Up @@ -103,7 +104,7 @@ def test_setting_default_provider_initializes_it():
registry = ProviderRegistry()
provider = Mock()

registry.set_default_provider(provider)
registry.set_default_provider(provider, wait_for_init=True)

provider.initialize.assert_called_once()

Expand All @@ -114,8 +115,8 @@ def test_registering_provider_as_default_then_domain_only_initializes_once():
registry = ProviderRegistry()
provider = Mock()

registry.set_default_provider(provider)
registry.set_provider("domain", provider)
registry.set_default_provider(provider, wait_for_init=True)
registry.set_provider("domain", provider, wait_for_init=True)

provider.initialize.assert_called_once()

Expand All @@ -126,8 +127,8 @@ def test_registering_provider_as_domain_then_default_only_initializes_once():
registry = ProviderRegistry()
provider = Mock()

registry.set_provider("domain", provider)
registry.set_default_provider(provider)
registry.set_provider("domain", provider, wait_for_init=True)
registry.set_default_provider(provider, wait_for_init=True)

provider.initialize.assert_called_once()

Expand Down Expand Up @@ -191,7 +192,7 @@ def test_initializing_provider_sets_status_ready():

assert registry.get_provider_status(provider) == ProviderStatus.NOT_READY

registry.set_provider("domain", provider)
registry.set_provider("domain", provider, wait_for_init=True)

provider.initialize.assert_called_once()
assert registry.get_provider_status(provider) == ProviderStatus.READY
Expand All @@ -203,7 +204,7 @@ def test_shutting_down_provider_sets_status_not_ready():
registry = ProviderRegistry()
provider = Mock()

registry.set_provider("domain", provider)
registry.set_provider("domain", provider, wait_for_init=True)
assert registry.get_provider_status(provider) == ProviderStatus.READY

registry.shutdown()
Expand All @@ -216,8 +217,8 @@ def test_clearing_registry_resets_providers_and_default():
registry = ProviderRegistry()
provider = Mock()

registry.set_provider("domain", provider)
registry.set_default_provider(provider)
registry.set_provider("domain", provider, wait_for_init=True)
registry.set_default_provider(provider, wait_for_init=True)

registry.clear_providers()

Expand All @@ -228,3 +229,53 @@ def test_clearing_registry_resets_providers_and_default():

provider.initialize.assert_called_once()
provider.shutdown.assert_called_once()


def test_set_provider_returns_before_initialization_completes():
"""Test that set_provider (non-blocking) returns before initialize finishes."""

registry = ProviderRegistry()
init_started = threading.Event()
init_may_proceed = threading.Event()
provider = Mock()

def slow_initialize(ctx):
init_started.set()
init_may_proceed.wait()

provider.initialize.side_effect = slow_initialize

registry.set_provider("domain", provider)

assert init_started.wait(timeout=2), "initialize was never called in background"
assert registry.get_provider_status(provider) == ProviderStatus.NOT_READY

init_may_proceed.set() # unblock the background thread


def test_set_provider_and_wait_blocks_until_ready():
"""Test that set_provider with wait_for_init=True blocks until READY."""

registry = ProviderRegistry()
initialized = threading.Event()
provider = Mock()

def tracking_initialize(ctx):
initialized.set()

provider.initialize.side_effect = tracking_initialize

registry.set_provider("domain", provider, wait_for_init=True)

assert initialized.is_set()
assert registry.get_provider_status(provider) == ProviderStatus.READY


def test_set_provider_and_wait_reraises_on_error():
"""Test that set_provider with wait_for_init=True re-raises initialization errors."""
registry = ProviderRegistry()
provider = Mock()
provider.initialize.side_effect = ProviderFatalError()

with pytest.raises(ProviderFatalError):
registry.set_provider("domain", provider, wait_for_init=True)
Loading
Loading