Skip to content

Commit e51858c

Browse files
committed
Merge remote-tracking branch 'origin/main' into jb/sdk-2792/retry-conformance
# Conflicts: # ldclient/impl/datasource/async_polling.py # ldclient/impl/datasource/polling.py # ldclient/testing/impl/datasource/test_async_polling.py
2 parents 619b21e + 9abe8cb commit e51858c

5 files changed

Lines changed: 109 additions & 10 deletions

File tree

‎ldclient/async_client.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ def __init__(self, config: AsyncConfig):
9191
self._event_factory_default = EventFactory(False)
9292
self._event_factory_with_reasons = EventFactory(True)
9393

94+
# One event loop runs the client. No await separates the check and the set of
95+
# these flags, so plain booleans are safe.
96+
self._eval_cached_data_warned = False
97+
self._all_flags_cached_data_warned = False
98+
9499
# Build the object graph here (loop-free). start() supplies the loop-bound
95100
# resources: the HTTP session (created lazily), the data source, the
96101
# big-segment poll, and the event processor. Evaluation before start()
@@ -496,7 +501,9 @@ async def _evaluate_internal(self, key: str, context: Context, default: Any, eve
496501
availability = await self._data_system.data_availability()
497502
if availability != DataAvailability.REFRESHED:
498503
if availability == DataAvailability.CACHED:
499-
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
504+
if not self._eval_cached_data_warned:
505+
self._eval_cached_data_warned = True
506+
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.")
500507
else:
501508
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
502509
reason = error_reason('CLIENT_NOT_READY')
@@ -568,7 +575,9 @@ async def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState
568575
availability = await self._data_system.data_availability()
569576
if availability != DataAvailability.REFRESHED:
570577
if availability == DataAvailability.CACHED:
571-
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store")
578+
if not self._all_flags_cached_data_warned:
579+
self._all_flags_cached_data_warned = True
580+
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.")
572581
else:
573582
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
574583
return FeatureFlagsState(False)

‎ldclient/client.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ def __init__(self, config: Config, start_wait: float = 5):
7878
self._event_factory_default = EventFactory(False)
7979
self._event_factory_with_reasons = EventFactory(True)
8080

81+
# Python has no lock-free atomic flag in the standard library. Code reaches this
82+
# lock only when data availability is cached, and only until the flag is set.
83+
self._cached_data_warning_lock = threading.Lock()
84+
self._eval_cached_data_warned = False
85+
self._all_flags_cached_data_warned = False
86+
8187
self.__start_up(start_wait)
8288

8389
def postfork(self, start_wait: float = 5):
@@ -412,7 +418,11 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
412418
availability = self._data_system.data_availability
413419
if availability != DataAvailability.REFRESHED:
414420
if availability == DataAvailability.CACHED:
415-
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
421+
if not self._eval_cached_data_warned:
422+
with self._cached_data_warning_lock:
423+
if not self._eval_cached_data_warned:
424+
self._eval_cached_data_warned = True
425+
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.")
416426
else:
417427
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
418428
reason = error_reason('CLIENT_NOT_READY')
@@ -484,7 +494,11 @@ def all_flags_state(self, context: Context, **kwargs) -> FeatureFlagsState:
484494
availability = self._data_system.data_availability
485495
if availability != DataAvailability.REFRESHED:
486496
if availability == DataAvailability.CACHED:
487-
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store")
497+
if not self._all_flags_cached_data_warned:
498+
with self._cached_data_warning_lock:
499+
if not self._all_flags_cached_data_warned:
500+
self._all_flags_cached_data_warned = True
501+
log.warning("all_flags_state() called before client has finished initializing! Using last known values from feature store. This message is logged once.")
488502
else:
489503
log.warning("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
490504
return FeatureFlagsState(False)

‎ldclient/testing/impl/datasource/test_async_polling.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -617,18 +617,16 @@ async def test_initialized_returns_false_before_first_poll(self):
617617
@pytest.mark.asyncio
618618
@patch('ldclient.config.Config.poll_interval', new_callable=MagicMock)
619619
async def test_second_start_call_is_a_no_op(self, mock_interval):
620-
# AsyncLDClient.start() is documented as an idempotent no-op, so
621-
# nothing underneath it may raise on a repeat call.
622620
mock_interval.__get__ = MagicMock(return_value=0)
623621

624622
processor = make_processor()
625623
processor._requester.get_all_data = AsyncMock(return_value=SAMPLE_DATA)
626624

627625
processor.start()
628-
first_task = processor._task
626+
task = processor._task
627+
# The task guards against a second start; it logs and does nothing.
629628
processor.start()
630-
631-
assert processor._task is first_task
629+
assert processor._task is task
632630

633631
await processor.stop()
634632

‎ldclient/testing/test_async_client.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import asyncio
6+
import logging
67
from unittest.mock import AsyncMock, MagicMock, patch
78

89
import pytest
@@ -500,3 +501,40 @@ async def fake_evaluate(flag, context, event_factory):
500501
assert flags_state['flag-good'].get('prerequisites') == ['prereq-x']
501502
assert 'prerequisites' not in flags_state['flag-bad-last']
502503
assert 'prerequisites' not in flags_state['flag-bad-first']
504+
505+
506+
class UninitializedAsyncUpdateProcessor(MockAsyncUpdateProcessor):
507+
def initialized(self) -> bool:
508+
return False
509+
510+
511+
@pytest.mark.asyncio
512+
async def test_cached_data_warnings_are_logged_once_per_client(caplog):
513+
caplog.set_level(logging.WARNING, logger='ldclient.util')
514+
store = MockAsyncFeatureStore()
515+
await store.force_set(FEATURES, _make_flag('my-flag', 'hello'))
516+
store._initialized = True
517+
config = AsyncConfig(
518+
"test-sdk-key",
519+
feature_store=store,
520+
update_processor_class=UninitializedAsyncUpdateProcessor,
521+
send_events=False,
522+
)
523+
client = AsyncLDClient(config)
524+
await client.start(start_wait=1.0)
525+
context = Context.create('user-1')
526+
527+
assert await client.variation('my-flag', context, 'default') == 'hello'
528+
assert await client.variation('my-flag', context, 'default') == 'hello'
529+
assert (await client.all_flags_state(context)).valid
530+
assert (await client.all_flags_state(context)).valid
531+
532+
warnings = [r.message for r in caplog.records if r.levelname == 'WARNING']
533+
eval_warnings = [m for m in warnings if m.startswith('Feature Flag evaluation attempted')]
534+
all_flags_warnings = [m for m in warnings if m.startswith('all_flags_state() called before')]
535+
assert len(eval_warnings) == 1
536+
assert len(all_flags_warnings) == 1
537+
assert eval_warnings[0].endswith('This message is logged once.')
538+
assert all_flags_warnings[0].endswith('This message is logged once.')
539+
540+
await client.close()

‎ldclient/testing/test_ldclient_evaluation.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import time
23

34
from ldclient.client import Config, Context, LDClient
@@ -10,7 +11,7 @@
1011
from ldclient.testing.builders import *
1112
from ldclient.testing.mock_components import MockBigSegmentStore
1213
from ldclient.testing.stub_util import MockEventProcessor, MockUpdateProcessor
13-
from ldclient.testing.test_ldclient import make_client, user
14+
from ldclient.testing.test_ldclient import make_client, unreachable_uri, user
1415
from ldclient.versioned_data_kind import FEATURES, SEGMENTS
1516

1617
flag1 = {'key': 'key1', 'version': 100, 'on': False, 'offVariation': 0, 'variations': ['value1'], 'trackEvents': False}
@@ -425,3 +426,42 @@ def fake_evaluate(flag, context, event_factory):
425426
assert metadata['good'].get('prerequisites') == ['prereq-of-good']
426427
assert 'prerequisites' not in metadata['bad-first']
427428
assert 'prerequisites' not in metadata['bad-last']
429+
430+
431+
class UninitializedUpdateProcessor(MockUpdateProcessor):
432+
def initialized(self):
433+
return False
434+
435+
436+
def make_client_with_cached_data(store):
437+
return LDClient(
438+
config=Config(
439+
sdk_key='SDK_KEY',
440+
base_uri=unreachable_uri,
441+
events_uri=unreachable_uri,
442+
stream_uri=unreachable_uri,
443+
event_processor_class=MockEventProcessor,
444+
update_processor_class=UninitializedUpdateProcessor,
445+
feature_store=store,
446+
)
447+
)
448+
449+
450+
def test_cached_data_warnings_are_logged_once_per_client(caplog):
451+
caplog.set_level(logging.WARNING, logger='ldclient.util')
452+
store = InMemoryFeatureStore()
453+
store.init({FEATURES: {'key1': flag1}})
454+
client = make_client_with_cached_data(store)
455+
456+
assert client.variation('key1', user, default='default') == 'value1'
457+
assert client.variation('key1', user, default='default') == 'value1'
458+
assert client.all_flags_state(user).valid
459+
assert client.all_flags_state(user).valid
460+
461+
warnings = get_log_lines(caplog, 'WARNING')
462+
eval_warnings = [m for m in warnings if m.startswith('Feature Flag evaluation attempted')]
463+
all_flags_warnings = [m for m in warnings if m.startswith('all_flags_state() called before')]
464+
assert len(eval_warnings) == 1
465+
assert len(all_flags_warnings) == 1
466+
assert eval_warnings[0].endswith('This message is logged once.')
467+
assert all_flags_warnings[0].endswith('This message is logged once.')

0 commit comments

Comments
 (0)