Skip to content

Commit 8421e98

Browse files
committed
feat: add set_provider_and_wait() for blocking initialization
Adds set_provider_and_wait() and set_provider_and_wait(domain) to the public API, implementing spec requirement 1.1.2.4: "The API SHOULD provide functions to set a provider and wait for the initialize function to return or abnormally terminate." This aligns the Python SDK with Java, Go, and Node.js SDKs which all offer both blocking and non-blocking provider registration. Behavior changes: - set_provider_and_wait(): calls initialize() synchronously on the caller's thread. If initialize() raises, the exception propagates to the caller and PROVIDER_ERROR is dispatched. - set_provider(): now runs initialize() in a background daemon thread. Errors are caught and dispatched as PROVIDER_ERROR events without propagating to the caller. Signed-off-by: Leo Romanovsky <leo.romanovsky@datadoghq.com>
1 parent 05382aa commit 8421e98

File tree

3 files changed

+189
-2
lines changed

3 files changed

+189
-2
lines changed

openfeature/api.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"remove_handler",
3232
"set_evaluation_context",
3333
"set_provider",
34+
"set_provider_and_wait",
3435
"set_transaction_context",
3536
"set_transaction_context_propagator",
3637
"shutdown",
@@ -44,12 +45,40 @@ def get_client(
4445

4546

4647
def set_provider(provider: FeatureProvider, domain: str | None = None) -> None:
48+
"""Set the provider and return immediately.
49+
50+
Initialization happens in a background thread. The provider starts in
51+
NOT_READY status and transitions to READY (or ERROR) once initialization
52+
completes. Use add_handler(ProviderEvent.PROVIDER_READY, ...) to be
53+
notified when the provider is ready for flag evaluation.
54+
55+
Spec reference: Requirement 1.1.2.2
56+
"""
4757
if domain is None:
4858
provider_registry.set_default_provider(provider)
4959
else:
5060
provider_registry.set_provider(domain, provider)
5161

5262

63+
def set_provider_and_wait(
64+
provider: FeatureProvider, domain: str | None = None
65+
) -> None:
66+
"""Set the provider and wait for initialization to complete.
67+
68+
Blocks the calling thread until the provider's initialize() method
69+
returns successfully or raises an exception. If initialization fails,
70+
the exception is re-raised to the caller.
71+
72+
Spec reference: Requirement 1.1.2.4 - "The API SHOULD provide functions
73+
to set a provider and wait for the initialize function to return or
74+
abnormally terminate."
75+
"""
76+
if domain is None:
77+
provider_registry.set_default_provider_and_wait(provider)
78+
else:
79+
provider_registry.set_provider_and_wait(domain, provider)
80+
81+
5382
def clear_providers() -> None:
5483
provider_registry.clear_providers()
5584
_event_support.clear()

openfeature/provider/_registry.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import threading
2+
13
from openfeature._event_support import run_handlers_for_provider
24
from openfeature.evaluation_context import EvaluationContext, get_evaluation_context
35
from openfeature.event import (
@@ -22,6 +24,16 @@ def __init__(self) -> None:
2224
}
2325

2426
def set_provider(self, domain: str, provider: FeatureProvider) -> None:
27+
self._set_provider_internal(domain, provider, wait=False)
28+
29+
def set_provider_and_wait(
30+
self, domain: str, provider: FeatureProvider
31+
) -> None:
32+
self._set_provider_internal(domain, provider, wait=True)
33+
34+
def _set_provider_internal(
35+
self, domain: str, provider: FeatureProvider, *, wait: bool
36+
) -> None:
2537
if provider is None:
2638
raise GeneralError(error_message="No provider")
2739
if domain is None:
@@ -36,7 +48,10 @@ def set_provider(self, domain: str, provider: FeatureProvider) -> None:
3648
):
3749
self._shutdown_provider(old_provider)
3850
if provider != self._default_provider and provider not in providers.values():
39-
self._initialize_provider(provider)
51+
if wait:
52+
self._initialize_provider(provider)
53+
else:
54+
self._initialize_provider_async(provider)
4055
providers[domain] = provider
4156

4257
def get_provider(self, domain: str | None) -> FeatureProvider:
@@ -45,6 +60,14 @@ def get_provider(self, domain: str | None) -> FeatureProvider:
4560
return self._providers.get(domain, self._default_provider)
4661

4762
def set_default_provider(self, provider: FeatureProvider) -> None:
63+
self._set_default_provider_internal(provider, wait=False)
64+
65+
def set_default_provider_and_wait(self, provider: FeatureProvider) -> None:
66+
self._set_default_provider_internal(provider, wait=True)
67+
68+
def _set_default_provider_internal(
69+
self, provider: FeatureProvider, *, wait: bool
70+
) -> None:
4871
if provider is None:
4972
raise GeneralError(error_message="No provider")
5073
if (
@@ -55,7 +78,10 @@ def set_default_provider(self, provider: FeatureProvider) -> None:
5578
self._default_provider = provider
5679

5780
if self._default_provider not in self._providers.values():
58-
self._initialize_provider(provider)
81+
if wait:
82+
self._initialize_provider(provider)
83+
else:
84+
self._initialize_provider_async(provider)
5985

6086
def get_default_provider(self) -> FeatureProvider:
6187
return self._default_provider
@@ -76,6 +102,7 @@ def _get_evaluation_context(self) -> EvaluationContext:
76102
return get_evaluation_context()
77103

78104
def _initialize_provider(self, provider: FeatureProvider) -> None:
105+
"""Initialize the provider synchronously, re-raising on failure."""
79106
provider.attach(self.dispatch_event)
80107
try:
81108
if hasattr(provider, "initialize"):
@@ -97,6 +124,36 @@ def _initialize_provider(self, provider: FeatureProvider) -> None:
97124
error_code=error_code,
98125
),
99126
)
127+
raise
128+
129+
def _initialize_provider_async(self, provider: FeatureProvider) -> None:
130+
"""Initialize the provider in a background thread, swallowing exceptions."""
131+
provider.attach(self.dispatch_event)
132+
133+
def _run() -> None:
134+
try:
135+
if hasattr(provider, "initialize"):
136+
provider.initialize(self._get_evaluation_context())
137+
self.dispatch_event(
138+
provider, ProviderEvent.PROVIDER_READY, ProviderEventDetails()
139+
)
140+
except Exception as err:
141+
error_code = (
142+
err.error_code
143+
if isinstance(err, OpenFeatureError)
144+
else ErrorCode.GENERAL
145+
)
146+
self.dispatch_event(
147+
provider,
148+
ProviderEvent.PROVIDER_ERROR,
149+
ProviderEventDetails(
150+
message=f"Provider initialization failed: {err}",
151+
error_code=error_code,
152+
),
153+
)
154+
155+
thread = threading.Thread(target=_run, daemon=True)
156+
thread.start()
100157

101158
def _shutdown_provider(self, provider: FeatureProvider) -> None:
102159
try:

tests/test_api.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
remove_handler,
1515
set_evaluation_context,
1616
set_provider,
17+
set_provider_and_wait,
1718
shutdown,
1819
)
1920
from openfeature.evaluation_context import EvaluationContext
@@ -330,6 +331,106 @@ def test_provider_error_handlers_run_if_provider_initialize_function_terminates_
330331
spy.provider_error.assert_called_once()
331332

332333

334+
def test_set_provider_and_wait_blocks_until_initialize_completes():
335+
# Given
336+
evaluation_context = EvaluationContext("targeting_key", {"attr1": "val1"})
337+
provider = MagicMock(spec=FeatureProvider)
338+
339+
# When
340+
set_evaluation_context(evaluation_context)
341+
set_provider_and_wait(provider)
342+
343+
# Then - initialize should have been called synchronously
344+
provider.initialize.assert_called_with(evaluation_context)
345+
# Provider should be READY after set_provider_and_wait returns
346+
client = get_client()
347+
assert client.get_provider_status() == ProviderStatus.READY
348+
349+
350+
def test_set_provider_and_wait_raises_on_initialization_failure():
351+
# Given
352+
provider = MagicMock(spec=FeatureProvider)
353+
provider.initialize.side_effect = ProviderFatalError()
354+
355+
spy = MagicMock()
356+
add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error)
357+
358+
# When / Then - should propagate the exception to the caller
359+
with pytest.raises(ProviderFatalError):
360+
set_provider_and_wait(provider)
361+
362+
# Error handler should still have been called
363+
spy.provider_error.assert_called_once()
364+
365+
366+
def test_set_provider_and_wait_with_domain():
367+
# Given
368+
provider = MagicMock(spec=FeatureProvider)
369+
370+
# When
371+
set_provider_and_wait(provider, domain="test")
372+
373+
# Then
374+
provider.initialize.assert_called_once()
375+
test_client = get_client("test")
376+
assert test_client.provider == provider
377+
378+
379+
def test_set_provider_runs_init_in_background():
380+
"""set_provider() should not raise even if initialize() fails."""
381+
import threading
382+
383+
# Given
384+
provider = MagicMock(spec=FeatureProvider)
385+
provider.initialize.side_effect = Exception("init failed")
386+
error_event = threading.Event()
387+
388+
def handler(_details):
389+
error_event.set()
390+
391+
add_handler(ProviderEvent.PROVIDER_ERROR, handler)
392+
393+
# When - should NOT raise (error is handled in background thread)
394+
set_provider(provider)
395+
396+
# Wait for the background thread to complete and fire the event
397+
assert error_event.wait(timeout=1), "PROVIDER_ERROR event not received"
398+
399+
# Then - provider should be in ERROR state (not READY)
400+
client = get_client()
401+
assert client.get_provider_status() == ProviderStatus.ERROR
402+
403+
# Cleanup
404+
remove_handler(ProviderEvent.PROVIDER_ERROR, handler)
405+
406+
407+
def test_set_provider_async_eventually_becomes_ready():
408+
"""set_provider() runs init in background, provider transitions to READY."""
409+
import threading
410+
411+
# Given
412+
provider = MagicMock(spec=FeatureProvider)
413+
ready_event = threading.Event()
414+
415+
def handler(_details):
416+
ready_event.set()
417+
418+
add_handler(ProviderEvent.PROVIDER_READY, handler)
419+
420+
# When
421+
set_provider(provider)
422+
423+
# Wait for the background thread to complete and fire the event
424+
assert ready_event.wait(timeout=1), "PROVIDER_READY event not received"
425+
426+
# Then - provider should be READY
427+
client = get_client()
428+
assert client.get_provider_status() == ProviderStatus.READY
429+
430+
# Cleanup
431+
remove_handler(ProviderEvent.PROVIDER_READY, handler)
432+
433+
333434
def test_provider_status_is_updated_after_provider_emits_event():
334435
# Given
335436
provider = NoOpProvider()

0 commit comments

Comments
 (0)