Skip to content

Commit bd6a1fa

Browse files
committed
fix: Do not propagate persistent-store errors from the sync FDv2 warm-start check
1 parent 5f44e61 commit bd6a1fa

3 files changed

Lines changed: 89 additions & 5 deletions

File tree

ldclient/client.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -402,14 +402,31 @@ def evaluate():
402402
hook_result = self.__evaluate_with_hooks(key=key, context=context, default_value=default_stage.value, method="migration_variation", block=evaluate)
403403
return hook_result.results['default_stage'], hook_result.results['tracker']
404404

405+
def _data_availability(self) -> DataAvailability:
406+
"""Reads the current data availability, degrading to ``DEFAULTS`` on error.
407+
408+
During the warm-start window (before a data source initializes) the
409+
availability gate may query a persistent store, such as Redis. A store
410+
I/O error must not propagate out of an evaluation, so treat it as "no
411+
data available"; the caller then returns the default value with
412+
``CLIENT_NOT_READY`` instead of raising.
413+
"""
414+
try:
415+
return self._data_system.data_availability
416+
except Exception as e:
417+
log.error("Error checking data availability; treating data as unavailable: %s" % repr(e))
418+
log.debug(traceback.format_exc())
419+
return DataAvailability.DEFAULTS
420+
405421
def _evaluate_internal(self, key: str, context: Context, default: Any, event_factory) -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
406422
default = self._config.get_default(key, default)
407423

408424
if self._config.offline:
409425
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None
410426

411-
if self._data_system.data_availability != DataAvailability.REFRESHED:
412-
if self._data_system.data_availability == DataAvailability.CACHED:
427+
availability = self._data_availability()
428+
if availability != DataAvailability.REFRESHED:
429+
if availability == DataAvailability.CACHED:
413430
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
414431
else:
415432
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
@@ -479,8 +496,9 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
479496
log.warning("all_flags_state() called, but client is in offline mode. Returning empty state")
480497
return FeatureFlagsState(False)
481498

482-
if self._data_system.data_availability != DataAvailability.REFRESHED:
483-
if self._data_system.data_availability == DataAvailability.CACHED:
499+
availability = self._data_availability()
500+
if availability != DataAvailability.REFRESHED:
501+
if availability == DataAvailability.CACHED:
484502
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store")
485503
else:
486504
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")

ldclient/impl/datasystem/fdv2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ def _consume_synchronizer_results(
392392
"""
393393
Consume results from a synchronizer until a condition is met or it fails.
394394
395-
:return: Tuple of (should_remove_sync, fallback_to_fdv1, directive)
395+
:return: the ConditionDirective describing how to proceed
396396
"""
397397
action_queue: Queue = Queue()
398398
timer = RepeatingTask(

ldclient/testing/impl/datasystem/test_fdv2_persistence.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
from threading import Event
44
from typing import Any, Callable, Dict, List, Mapping, Optional
55

6+
import pytest
7+
8+
from ldclient.client import Context
69
from ldclient.config import Config, DataSystemConfig
710
from ldclient.impl.datasystem import DataAvailability
811
from ldclient.impl.datasystem.fdv2 import FDv2
912
from ldclient.integrations.test_datav2 import TestDataV2
1013
from ldclient.interfaces import DataStoreMode, FeatureStore, FlagChange
14+
from ldclient.testing.test_ldclient import make_client
1115
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind
1216

1317

@@ -782,3 +786,65 @@ def init(self, all_data):
782786
assert err is not None, "Commit should return error from persistent store"
783787
assert isinstance(err, RuntimeError)
784788
assert str(err) == "Simulated persistent store failure"
789+
790+
791+
class ThrowingInitializedStore(StubFeatureStore):
792+
"""A persistent store whose ``initialized`` check raises, to simulate a
793+
store I/O error (for example a Redis connection failure) during the
794+
warm-start availability gate."""
795+
796+
@property
797+
def initialized(self) -> bool:
798+
raise RuntimeError("persistent store I/O error")
799+
800+
801+
def test_variation_does_not_throw_when_persistent_store_errors_during_warm_start(caplog):
802+
"""A persistent-store error at the warm-start availability gate must not
803+
propagate out of the client.
804+
805+
While a synchronizer is configured but has not yet supplied a basis, the
806+
availability gate consults the persistent store's initialized state. If that
807+
query raises, evaluation must degrade to the default value with
808+
``CLIENT_NOT_READY`` and ``all_flags_state()`` must return an invalid state,
809+
rather than raising. This is the sync counterpart to the async fix in #486.
810+
"""
811+
persistent_store = ThrowingInitializedStore()
812+
813+
# A synchronizer is configured but the data system is never started, so no
814+
# basis arrives. This is the warm-start window in which the gate reads the
815+
# persistent store's initialized state.
816+
data_system_config = DataSystemConfig(
817+
data_store_mode=DataStoreMode.READ_ONLY,
818+
data_store=persistent_store,
819+
initializers=None,
820+
synchronizers=[TestDataV2.data_source().builder],
821+
)
822+
fdv2 = FDv2(Config(sdk_key="dummy"), data_system_config)
823+
824+
# The gate itself raises: this is the unguarded root the fix addresses.
825+
with pytest.raises(RuntimeError):
826+
_ = fdv2.data_availability
827+
828+
# Drive the same failing gate through the client and confirm it degrades
829+
# instead of propagating the error.
830+
client = make_client()
831+
try:
832+
client._data_system = fdv2
833+
context = Context.from_dict({"key": "user", "kind": "user"})
834+
835+
assert client.variation("flag-key", context, default="default-value") == "default-value"
836+
837+
detail = client.variation_detail("flag-key", context, default="default-value")
838+
assert detail.value == "default-value"
839+
assert detail.reason == {"kind": "ERROR", "errorKind": "CLIENT_NOT_READY"}
840+
assert detail.is_default_value() is True
841+
842+
assert client.all_flags_state(context).valid is False
843+
finally:
844+
client.close()
845+
846+
assert any(
847+
"Error checking data availability" in record.message
848+
for record in caplog.records
849+
if record.levelname == "ERROR"
850+
)

0 commit comments

Comments
 (0)