Skip to content

Commit 68864cc

Browse files
committed
fix: synchronize provider shutdown state
1 parent 6a89dc1 commit 68864cc

3 files changed

Lines changed: 92 additions & 36 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,11 @@ FeatBit states are mapped as follows:
9292
| initializing | `NOT_READY` during provider initialization |
9393
| OK | `READY` |
9494
| interrupted | `STALE` |
95-
| normal off during provider shutdown | `NOT_READY` |
96-
| permanently off with an error | `FATAL` |
97-
| provider shutdown | `NOT_READY` |
95+
| off observed while provider is active | `FATAL` |
96+
97+
After `api.shutdown()`, OpenFeature removes this provider and installs its default
98+
no-op provider. Status after that point belongs to the OpenFeature SDK rather than
99+
to FeatBit.
98100

99101
The included `py.typed` marker declares this package as PEP 561 typed, allowing
100102
type checkers to use the annotations shipped with the provider after install.

‎featbit_openfeature/provider.py‎

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ def __init__(self, client: FBClient, initialization_timeout: float = 15.0):
6262
self._initialization_timeout = initialization_timeout
6363
self._context_converter = EvaluationContextConverter()
6464
self._details_converter = ResolutionDetailsConverter()
65-
self._lifecycle_lock = threading.Lock()
65+
# Listener registration and event emission can synchronously re-enter
66+
# provider lifecycle methods through user callbacks.
67+
self._lifecycle_lock = threading.RLock()
68+
self._shutdown_started = False
6669
self._flag_change_listener: _FlagChangeListener | None = None
6770
self._status_listener_registered = False
6871

@@ -75,19 +78,23 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
7578
# Register before waiting so a client that connects just after an
7679
# initialization timeout can still publish its recovery to OpenFeature.
7780
self._register_listeners()
78-
if not self._client.initialize:
79-
ready = self._client.update_status_provider.wait_for_OKState(
80-
self._initialization_timeout
81-
)
82-
if not ready:
83-
state = self._client.update_status_provider.current_state
84-
message = self._state_message(state)
85-
if state.state_type == StateType.OFF:
86-
raise ProviderFatalError(message)
87-
raise ProviderNotReadyError(message)
81+
with self._lifecycle_lock:
82+
if self._shutdown_started:
83+
raise ProviderFatalError("FeatBit provider is shut down")
84+
85+
ready = self._client.update_status_provider.wait_for_OKState(
86+
self._initialization_timeout
87+
)
88+
if not ready:
89+
state = self._client.update_status_provider.current_state
90+
message = self._state_message(state)
91+
if state.state_type == StateType.OFF:
92+
raise ProviderFatalError(message)
93+
raise ProviderNotReadyError(message)
8894

8995
def shutdown(self) -> None:
9096
with self._lifecycle_lock:
97+
self._shutdown_started = True
9198
flag_listener = self._flag_change_listener
9299
status_listener_registered = self._status_listener_registered
93100

@@ -264,6 +271,9 @@ def _type_mismatch_details(default_value: Any) -> FlagResolutionDetails[Any]:
264271

265272
def _register_listeners(self) -> None:
266273
with self._lifecycle_lock:
274+
if self._shutdown_started:
275+
return
276+
267277
if not self._status_listener_registered:
268278
try:
269279
self._client.update_status_provider.add_listener(
@@ -285,32 +295,33 @@ def _register_listeners(self) -> None:
285295

286296
def _emit_configuration_changed(self, flag_key: str) -> None:
287297
try:
288-
self.emit_provider_configuration_changed(
289-
ProviderEventDetails(flags_changed=[flag_key])
290-
)
298+
with self._lifecycle_lock:
299+
if self._shutdown_started:
300+
return
301+
self.emit_provider_configuration_changed(
302+
ProviderEventDetails(flags_changed=[flag_key])
303+
)
291304
except Exception:
292305
logger.exception("Failed to emit OpenFeature configuration change event")
293306

294307
def _handle_data_update_status(self, state: State) -> None:
295308
try:
296-
if state.state_type == StateType.OK:
297-
self.emit_provider_ready(ProviderEventDetails())
298-
elif state.state_type == StateType.INTERRUPTED:
299-
self.emit_provider_stale(
300-
ProviderEventDetails(message=self._state_message(state))
301-
)
302-
elif state.state_type == StateType.OFF:
303-
if state.error_track is not None:
309+
with self._lifecycle_lock:
310+
if self._shutdown_started:
311+
return
312+
if state.state_type == StateType.OK:
313+
self.emit_provider_ready(ProviderEventDetails())
314+
elif state.state_type == StateType.INTERRUPTED:
315+
self.emit_provider_stale(
316+
ProviderEventDetails(message=self._state_message(state))
317+
)
318+
elif state.state_type == StateType.OFF:
304319
self.emit_provider_error(
305320
ProviderEventDetails(
306321
error_code=ErrorCode.PROVIDER_FATAL,
307322
message=self._state_message(state),
308323
)
309324
)
310-
# A normal OFF is produced by FBClient.stop(). Provider
311-
# shutdown unregisters this listener before stopping the
312-
# client; OpenFeature then removes the provider status, whose
313-
# standard fallback is NOT_READY. Do not misreport it FATAL.
314325
except Exception:
315326
logger.exception("Failed to emit OpenFeature provider status event")
316327

‎tests/test_provider.py‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from concurrent.futures import ThreadPoolExecutor
2+
from threading import Event, Thread
23
from unittest.mock import Mock
34

45
import pytest
@@ -26,6 +27,7 @@ def make_client(initialized=True):
2627
client.initialize = initialized
2728
client.flag_tracker = Mock()
2829
client.update_status_provider = Mock()
30+
client.update_status_provider.wait_for_OKState.return_value = True
2931
return client
3032

3133

@@ -149,7 +151,7 @@ def test_initialize_returns_immediately_when_client_is_ready():
149151

150152
provider.initialize(EvaluationContext())
151153

152-
client.update_status_provider.wait_for_OKState.assert_not_called()
154+
client.update_status_provider.wait_for_OKState.assert_called_once_with(15.0)
153155
client.flag_tracker.add_flag_changed_listener.assert_called_once()
154156

155157

@@ -256,6 +258,48 @@ def test_shutdown_never_exposes_client_stop_failure():
256258
client.stop.assert_called_once_with()
257259

258260

261+
def test_shutdown_prevents_concurrent_listener_registration_and_events():
262+
client = make_client()
263+
provider = FeatBitProvider(client)
264+
emitter = Mock()
265+
provider.attach(emitter)
266+
stop_entered = Event()
267+
release_stop = Event()
268+
269+
def blocking_stop():
270+
stop_entered.set()
271+
release_stop.wait(timeout=2)
272+
273+
client.stop.side_effect = blocking_stop
274+
shutdown_thread = Thread(target=provider.shutdown)
275+
shutdown_thread.start()
276+
assert stop_entered.wait(timeout=2)
277+
278+
provider._register_listeners()
279+
provider._handle_data_update_status(State.ok_state())
280+
provider._emit_configuration_changed("checkout")
281+
282+
release_stop.set()
283+
shutdown_thread.join(timeout=2)
284+
assert not shutdown_thread.is_alive()
285+
client.update_status_provider.add_listener.assert_not_called()
286+
client.flag_tracker.add_flag_changed_listener.assert_not_called()
287+
emitter.assert_not_called()
288+
289+
290+
def test_initialize_after_shutdown_is_fatal_and_registers_no_listeners():
291+
client = make_client()
292+
provider = FeatBitProvider(client)
293+
provider.shutdown()
294+
295+
with pytest.raises(ProviderFatalError):
296+
provider.initialize(EvaluationContext())
297+
298+
client.update_status_provider.add_listener.assert_not_called()
299+
client.flag_tracker.add_flag_changed_listener.assert_not_called()
300+
client.update_status_provider.wait_for_OKState.assert_not_called()
301+
302+
259303
def test_runtime_featbit_status_updates_openfeature_provider_status():
260304
client = make_client()
261305
provider = FeatBitProvider(client)
@@ -273,17 +317,16 @@ def test_runtime_featbit_status_updates_openfeature_provider_status():
273317
listener(State.ok_state())
274318
assert openfeature_client.get_provider_status() == ProviderStatus.READY
275319

276-
# A clean OFF is the result of lifecycle shutdown and must not be
277-
# reported as a fatal provider failure.
320+
# Any OFF observed while the provider is active means the underlying
321+
# client can no longer evaluate flags safely.
278322
listener(State.normal_off_state())
279-
assert openfeature_client.get_provider_status() == ProviderStatus.READY
280-
281-
listener(State.error_off_state("credential", "invalid secret"))
282323
assert openfeature_client.get_provider_status() == ProviderStatus.FATAL
283324
finally:
284325
api.shutdown()
285326

286-
assert openfeature_client.get_provider_status() == ProviderStatus.NOT_READY
327+
# OpenFeature shutdown replaces the registered provider with its ready
328+
# no-op provider; the FeatBit provider itself no longer emits events.
329+
assert openfeature_client.get_provider_status() == ProviderStatus.READY
287330
client.stop.assert_called_once_with()
288331

289332

0 commit comments

Comments
 (0)