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
57 changes: 43 additions & 14 deletions ldclient/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def __init__(self, config: AsyncConfig):
# these flags, so plain booleans are safe.
self._eval_cached_data_warned = False
self._all_flags_cached_data_warned = False
self._all_flags_overrides_only_warned = False

# Build the object graph here (loop-free). start() supplies the loop-bound
# resources: the HTTP session (created lazily), the data source, the
Expand Down Expand Up @@ -498,30 +499,42 @@ async def _evaluate_internal(self, key: str, context: Context, default: Any, eve
if self._config.offline:
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None

def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
reason = error_reason('CLIENT_NOT_READY')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
return EvaluationDetail(default, None, reason), None

flag: Any = None
availability = await self._data_system.data_availability()
if availability != DataAvailability.REFRESHED:
if availability == DataAvailability.CACHED:
if not self._eval_cached_data_warned:
self._eval_cached_data_warned = True
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key + ". This message is logged once.")
else:
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
reason = error_reason('CLIENT_NOT_READY')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
return EvaluationDetail(default, None, reason), None
# No data from LaunchDarkly is available. A flag that the override layer holds
# is served anyway. A flag the layer does not hold gets the not-ready default,
# as it does without an override source.
layer = self._data_system.override_layer
if layer is not None:
flag = layer.get(FEATURES, key)
if flag is None:
return not_ready()

if not context.valid:
log.warning("Context was invalid for flag evaluation (%s); returning default value" % context.error)
return EvaluationDetail(default, None, error_reason('USER_NOT_SPECIFIED')), None

try:
flag = await self._data_system.store.get(FEATURES, key)
except Exception as e:
log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e)))
log.debug(traceback.format_exc())
reason = error_reason('EXCEPTION')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
return EvaluationDetail(default, None, reason), None
if flag is None:
try:
flag = await self._data_system.store.get(FEATURES, key)
except Exception as e:
log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e)))
log.debug(traceback.format_exc())
reason = error_reason('EXCEPTION')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
return EvaluationDetail(default, None, reason), None
if not flag:
reason = error_reason('FLAG_NOT_FOUND')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
Expand Down Expand Up @@ -572,15 +585,23 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState
log.warning("all_flags_state() called, but client is in offline mode. Returning empty state")
return FeatureFlagsState(False)

overrides_only = False
availability = await self._data_system.data_availability()
if availability != DataAvailability.REFRESHED:
if availability == DataAvailability.CACHED:
if not self._all_flags_cached_data_warned:
self._all_flags_cached_data_warned = True
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.")
else:
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
return FeatureFlagsState(False)
# No data from LaunchDarkly is available. When the override layer holds entries,
# the state is read through the overlay, which returns the override entries and
# any data the store already holds. Otherwise the state is unavailable, as it is
# without an override source.
layer = self._data_system.override_layer
if layer is None or layer.is_empty:
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
return FeatureFlagsState(False)
overrides_only = True

if not context.valid:
log.warning("Context was invalid for all_flags_state (%s); returning default value" % context.error)
Expand All @@ -598,6 +619,14 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState
log.error("Unable to read flags for all_flag_state: %s" % repr(e))
return FeatureFlagsState(False)

if overrides_only:
if len(flags_map) == 0:
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
return FeatureFlagsState(False)
if not self._all_flags_overrides_only_warned:
self._all_flags_overrides_only_warned = True
log.warning("all_flags_state() called before client has finished initializing! Returning only flags from the override layer. This message is logged once.")

for key, flag in flags_map.items():
if client_only and not flag.get('clientSide', False):
continue
Expand Down
8 changes: 8 additions & 0 deletions ldclient/async_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
DataSourceBuilder,
DataSourceBuilderConfig,
HTTPConfig,
OverrideSourceBuilder,
PrivateAttributesConfig
)
from ldclient.hook import AsyncHook
Expand Down Expand Up @@ -127,6 +128,13 @@ class AsyncDataSystemConfig:
fdv1_fallback_synchronizer: Optional[DataSourceBuilder[AsyncSynchronizer]] = None
"""An optional fallback synchronizer that will read from FDv1"""

override_source: Optional[OverrideSourceBuilder] = None
"""
An optional override source. Its flag and segment definitions take precedence over
LaunchDarkly data at evaluation time. Flag overrides are currently experimental and subject
to change.
"""


class AsyncConfig(DataSourceBuilderConfig, PrivateAttributesConfig):
"""Advanced configuration options for the async SDK client.
Expand Down
76 changes: 61 additions & 15 deletions ldclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def __init__(self, config: Config, start_wait: float = 5):
self._cached_data_warning_lock = threading.Lock()
self._eval_cached_data_warned = False
self._all_flags_cached_data_warned = False
self._all_flags_overrides_only_warned = False

self._owner_pid = os.getpid()
self._fork_warned_pids: Dict[int, object] = {}
Expand Down Expand Up @@ -211,7 +212,13 @@ def __start_up(self, start_wait: float):
self.__register_plugins(environment_metadata)

update_processor_ready = threading.Event()
self._data_system.start(update_processor_ready)
try:
self._data_system.start(update_processor_ready)
except BaseException:
# A data system that fails to start fails the constructor. The components that
# are already running stop here, so the failure leaves no threads behind.
self._close_components()
raise

if not self._config.offline and not self._config.use_ldd:
if start_wait > 60:
Expand Down Expand Up @@ -281,6 +288,15 @@ def close(self):
self._data_system.stop()
self.__big_segment_store_manager.stop()

def _close_components(self):
"""
Stops the SDK components after a failed start-up. The event processor is last because
it may still be sending events the other components generated.
"""
self._data_system.stop()
self.__big_segment_store_manager.stop()
self._event_processor.stop()

# These magic methods allow a client object to be automatically cleaned up by the "with" scope operator
def __enter__(self):
return self
Expand Down Expand Up @@ -467,6 +483,13 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
if self._config.offline:
return EvaluationDetail(default, None, error_reason('CLIENT_NOT_READY')), None

def not_ready() -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
reason = error_reason('CLIENT_NOT_READY')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
return EvaluationDetail(default, None, reason), None

flag: Any = None
availability = self._data_system.data_availability
if availability != DataAvailability.REFRESHED:
if availability == DataAvailability.CACHED:
Expand All @@ -476,23 +499,28 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
self._eval_cached_data_warned = True
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key + ". This message is logged once.")
else:
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
reason = error_reason('CLIENT_NOT_READY')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
return EvaluationDetail(default, None, reason), None
# No data from LaunchDarkly is available. A flag that the override layer holds
# is served anyway. A flag the layer does not hold gets the not-ready default,
# as it does without an override source.
layer = self._data_system.override_layer
if layer is not None:
flag = layer.get(FEATURES, key)
if flag is None:
return not_ready()

if not context.valid:
log.warning("Context was invalid for flag evaluation (%s); returning default value" % context.error)
return EvaluationDetail(default, None, error_reason('USER_NOT_SPECIFIED')), None

try:
flag = self._data_system.store.get(FEATURES, key)
except Exception as e:
log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e)))
log.debug(traceback.format_exc())
reason = error_reason('EXCEPTION')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
return EvaluationDetail(default, None, reason), None
if flag is None:
try:
flag = self._data_system.store.get(FEATURES, key)
except Exception as e:
log.error("Unexpected error while retrieving feature flag \"%s\": %s" % (key, repr(e)))
log.debug(traceback.format_exc())
reason = error_reason('EXCEPTION')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
return EvaluationDetail(default, None, reason), None
if not flag:
reason = error_reason('FLAG_NOT_FOUND')
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
Expand Down Expand Up @@ -545,6 +573,7 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:

self._check_forked()

overrides_only = False
availability = self._data_system.data_availability
if availability != DataAvailability.REFRESHED:
if availability == DataAvailability.CACHED:
Expand All @@ -554,8 +583,15 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
self._all_flags_cached_data_warned = True
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.")
else:
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
return FeatureFlagsState(False)
# No data from LaunchDarkly is available. When the override layer holds entries,
# the state is read through the overlay, which returns the override entries and
# any data the store already holds. Otherwise the state is unavailable, as it is
# without an override source.
layer = self._data_system.override_layer
if layer is None or layer.is_empty:
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
return FeatureFlagsState(False)
overrides_only = True

if not context.valid:
log.warning("Context was invalid for all_flags_state (%s); returning default value" % context.error)
Expand All @@ -573,6 +609,16 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
log.error("Unable to read flags for all_flag_state: %s" % repr(e))
return FeatureFlagsState(False)

if overrides_only:
if len(flags_map) == 0:
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
return FeatureFlagsState(False)
if not self._all_flags_overrides_only_warned:
with self._cached_data_warning_lock:
if not self._all_flags_overrides_only_warned:
self._all_flags_overrides_only_warned = True
log.warning("all_flags_state() called before client has finished initializing! Returning only flags from the override layer. This message is logged once.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All-flags includes unavailable store flags

Medium Severity

When availability is DEFAULTS and the override layer is non-empty, all_flags_state reads through the overlay instead of the layer. The overlay unions in whatever the base store still holds, so flags from an uninitialized persistent store can appear in a valid state even though variation() returns CLIENT_NOT_READY for those keys. The once-per-client warning also claims the result contains only override-layer flags.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1c1bd5c. Configure here.


for key, flag in flags_map.items():
if client_only and not flag.get('clientSide', False):
continue
Expand Down
27 changes: 26 additions & 1 deletion ldclient/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
EventProcessor,
FeatureStore,
Initializer,
OverrideSource,
Synchronizer,
UpdateProcessor
)
Expand Down Expand Up @@ -255,6 +256,23 @@ def build(self, config: DataSourceBuilderConfig) -> T_co:
raise NotImplementedError


class OverrideSourceBuilder(Protocol): # pylint: disable=too-few-public-methods
"""
Protocol for building an override source. Flag overrides are currently experimental and
subject to change.
"""

def build(self, config: DataSourceBuilderConfig) -> OverrideSource:
"""
Builds the override source. Invalid configuration raises an exception, which the client
constructor propagates.

:param config: the SDK configuration
:return: the built override source
"""
raise NotImplementedError


@dataclass(frozen=True)
class DataSystemConfig:
"""Configuration for LaunchDarkly's data acquisition strategy."""
Expand All @@ -278,6 +296,13 @@ class DataSystemConfig:
fdv1_fallback_synchronizer: Optional[DataSourceBuilder[Synchronizer]] = None
"""An optional fallback synchronizer that will read from FDv1"""

override_source: Optional[OverrideSourceBuilder] = None
"""
An optional override source. Its flag and segment definitions take precedence over
LaunchDarkly data at evaluation time. Flag overrides are currently experimental and subject
to change.
"""


class Config(DataSourceBuilderConfig, PrivateAttributesConfig):
"""Advanced configuration options for the SDK client.
Expand Down Expand Up @@ -715,4 +740,4 @@ def _validate(self):
log.warning("Missing or blank SDK key")


__all__ = ['Config', 'BigSegmentsConfig', 'DataSourceBuilder', 'DataSystemConfig', 'HTTPConfig']
__all__ = ['Config', 'BigSegmentsConfig', 'DataSourceBuilder', 'DataSystemConfig', 'HTTPConfig', 'OverrideSourceBuilder']
37 changes: 36 additions & 1 deletion ldclient/datasystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

from typing import List, Optional

from ldclient.config import DataSourceBuilder, DataSystemConfig
from ldclient.config import (
DataSourceBuilder,
DataSystemConfig,
OverrideSourceBuilder
)
from ldclient.impl.datasourcev2.polling import (
FallbackToFDv1PollingDataSourceBuilder,
PollingDataSourceBuilder
Expand Down Expand Up @@ -32,6 +36,7 @@ def __init__(self) -> None:
self._fdv1_fallback_synchronizer: Optional[DataSourceBuilder[Synchronizer]] = None
self._store_mode: DataStoreMode = DataStoreMode.READ_ONLY
self._data_store: Optional[FeatureStore] = None
self._override_source: Optional[OverrideSourceBuilder] = None

def initializers(self, initializers: Optional[List[DataSourceBuilder[Initializer]]]) -> "ConfigBuilder":
"""
Expand Down Expand Up @@ -80,6 +85,35 @@ def data_store(self, data_store: FeatureStore, store_mode: DataStoreMode) -> "Co
self._store_mode = store_mode
return self

def overrides(self, source: OverrideSourceBuilder) -> "ConfigBuilder":
"""
Configures the SDK with an override source. Flag overrides are currently experimental
and subject to change.

The source supplies flag and segment definitions that take precedence over data
received from LaunchDarkly on a per-key basis. Overrides let an operator force one or
more flags to a known state on a running client, whether or not the client can reach
LaunchDarkly. Flags not present in the override data are unaffected.

The override source is not a data source. It has no effect on the client's
initialization status or data source status. Configuring it changes nothing until the
source actually supplies an override. At most one override source is accepted.

Example, with the file-based source and the SDK's default data sources:
::

from ldclient import datasystem
from ldclient.integrations.overrides import FileOverrideSourceBuilder

source = FileOverrideSourceBuilder(['/etc/launchdarkly/overrides.json'])
config = Config(sdk_key, datasystem_config=datasystem.default().overrides(source).build())

:param source: a builder for the override source, such as
:class:`ldclient.integrations.overrides.FileOverrideSourceBuilder`
"""
self._override_source = source
return self

def build(self) -> DataSystemConfig:
"""
Builds the data system configuration.
Expand All @@ -90,6 +124,7 @@ def build(self) -> DataSystemConfig:
fdv1_fallback_synchronizer=self._fdv1_fallback_synchronizer,
data_store_mode=self._store_mode,
data_store=self._data_store,
override_source=self._override_source,
)


Expand Down
Loading
Loading