growthbook 1.4.3__tar.gz → 1.4.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {growthbook-1.4.3/growthbook.egg-info → growthbook-1.4.5}/PKG-INFO +1 -1
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/__init__.py +1 -1
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/common_types.py +1 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/growthbook.py +89 -8
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/growthbook_client.py +28 -2
- {growthbook-1.4.3 → growthbook-1.4.5/growthbook.egg-info}/PKG-INFO +1 -1
- {growthbook-1.4.3 → growthbook-1.4.5}/setup.cfg +1 -1
- {growthbook-1.4.3 → growthbook-1.4.5}/tests/conftest.py +2 -1
- {growthbook-1.4.3 → growthbook-1.4.5}/tests/test_growthbook.py +239 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/tests/test_growthbook_client.py +124 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/LICENSE +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/MANIFEST.in +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/README.md +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/core.py +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/plugins/base.py +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/plugins/growthbook_tracking.py +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/plugins/request_context.py +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/py.typed +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook.egg-info/SOURCES.txt +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/pyproject.toml +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/setup.py +0 -0
- {growthbook-1.4.3 → growthbook-1.4.5}/tests/test_plugins.py +0 -0
|
@@ -426,6 +426,7 @@ class Options:
|
|
|
426
426
|
sticky_bucket_service: Optional[AbstractStickyBucketService] = None
|
|
427
427
|
sticky_bucket_identifier_attributes: Optional[List[str]] = None
|
|
428
428
|
on_experiment_viewed: Optional[Callable[[Experiment, Result, Optional[UserContext]], None]] = None
|
|
429
|
+
on_feature_usage: Optional[Callable[[str, 'FeatureResult'], None]] = None
|
|
429
430
|
tracking_plugins: Optional[List[Any]] = None
|
|
430
431
|
|
|
431
432
|
|
|
@@ -100,7 +100,8 @@ class InMemoryFeatureCache(AbstractFeatureCache):
|
|
|
100
100
|
def set(self, key: str, value: Dict, ttl: int) -> None:
|
|
101
101
|
if key in self.cache:
|
|
102
102
|
self.cache[key].update(value)
|
|
103
|
-
|
|
103
|
+
else:
|
|
104
|
+
self.cache[key] = CacheEntry(value, ttl)
|
|
104
105
|
|
|
105
106
|
def clear(self) -> None:
|
|
106
107
|
self.cache.clear()
|
|
@@ -327,6 +328,11 @@ class FeatureRepository(object):
|
|
|
327
328
|
self.http: Optional[PoolManager] = None
|
|
328
329
|
self.sse_client: Optional[SSEClient] = None
|
|
329
330
|
self._feature_update_callbacks: List[Callable[[Dict], None]] = []
|
|
331
|
+
|
|
332
|
+
# Background refresh support
|
|
333
|
+
self._refresh_thread: Optional[threading.Thread] = None
|
|
334
|
+
self._refresh_stop_event = threading.Event()
|
|
335
|
+
self._refresh_lock = threading.Lock()
|
|
330
336
|
|
|
331
337
|
def set_cache(self, cache: AbstractFeatureCache) -> None:
|
|
332
338
|
self.cache = cache
|
|
@@ -375,6 +381,7 @@ class FeatureRepository(object):
|
|
|
375
381
|
return res
|
|
376
382
|
return cached
|
|
377
383
|
|
|
384
|
+
|
|
378
385
|
async def load_features_async(
|
|
379
386
|
self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600
|
|
380
387
|
) -> Optional[Dict]:
|
|
@@ -493,6 +500,52 @@ class FeatureRepository(object):
|
|
|
493
500
|
if self.sse_client:
|
|
494
501
|
self.sse_client.disconnect(timeout=timeout)
|
|
495
502
|
self.sse_client = None
|
|
503
|
+
|
|
504
|
+
def start_background_refresh(self, api_host: str, client_key: str, decryption_key: str, ttl: int = 600, refresh_interval: int = 300) -> None:
|
|
505
|
+
"""Start periodic background refresh task"""
|
|
506
|
+
with self._refresh_lock:
|
|
507
|
+
if self._refresh_thread is not None:
|
|
508
|
+
return # Already running
|
|
509
|
+
|
|
510
|
+
self._refresh_stop_event.clear()
|
|
511
|
+
self._refresh_thread = threading.Thread(
|
|
512
|
+
target=self._background_refresh_worker,
|
|
513
|
+
args=(api_host, client_key, decryption_key, ttl, refresh_interval),
|
|
514
|
+
daemon=True
|
|
515
|
+
)
|
|
516
|
+
self._refresh_thread.start()
|
|
517
|
+
logger.debug("Started background refresh task")
|
|
518
|
+
|
|
519
|
+
def _background_refresh_worker(self, api_host: str, client_key: str, decryption_key: str, ttl: int, refresh_interval: int) -> None:
|
|
520
|
+
"""Worker method for periodic background refresh"""
|
|
521
|
+
while not self._refresh_stop_event.is_set():
|
|
522
|
+
try:
|
|
523
|
+
# Wait for the refresh interval or stop event
|
|
524
|
+
if self._refresh_stop_event.wait(refresh_interval):
|
|
525
|
+
break # Stop event was set
|
|
526
|
+
|
|
527
|
+
logger.debug("Background refresh for Features - started")
|
|
528
|
+
res = self._fetch_features(api_host, client_key, decryption_key)
|
|
529
|
+
if res is not None:
|
|
530
|
+
cache_key = api_host + "::" + client_key
|
|
531
|
+
self.cache.set(cache_key, res, ttl)
|
|
532
|
+
logger.debug("Background refresh completed")
|
|
533
|
+
# Notify callbacks about fresh features
|
|
534
|
+
self._notify_feature_update_callbacks(res)
|
|
535
|
+
else:
|
|
536
|
+
logger.warning("Background refresh failed")
|
|
537
|
+
except Exception as e:
|
|
538
|
+
logger.warning(f"Background refresh error: {e}")
|
|
539
|
+
|
|
540
|
+
def stop_background_refresh(self) -> None:
|
|
541
|
+
"""Stop background refresh task"""
|
|
542
|
+
self._refresh_stop_event.set()
|
|
543
|
+
|
|
544
|
+
with self._refresh_lock:
|
|
545
|
+
if self._refresh_thread is not None:
|
|
546
|
+
self._refresh_thread.join(timeout=1.0) # Wait up to 1 second
|
|
547
|
+
self._refresh_thread = None
|
|
548
|
+
logger.debug("Stopped background refresh task")
|
|
496
549
|
|
|
497
550
|
@staticmethod
|
|
498
551
|
def _get_features_url(api_host: str, client_key: str) -> str:
|
|
@@ -512,6 +565,7 @@ class GrowthBook(object):
|
|
|
512
565
|
features: dict = {},
|
|
513
566
|
qa_mode: bool = False,
|
|
514
567
|
on_experiment_viewed=None,
|
|
568
|
+
on_feature_usage=None,
|
|
515
569
|
api_host: str = "",
|
|
516
570
|
client_key: str = "",
|
|
517
571
|
decryption_key: str = "",
|
|
@@ -522,6 +576,8 @@ class GrowthBook(object):
|
|
|
522
576
|
savedGroups: dict = {},
|
|
523
577
|
streaming: bool = False,
|
|
524
578
|
streaming_connection_timeout: int = 30,
|
|
579
|
+
stale_while_revalidate: bool = False,
|
|
580
|
+
stale_ttl: int = 300, # 5 minutes default
|
|
525
581
|
plugins: List = None,
|
|
526
582
|
# Deprecated args
|
|
527
583
|
trackingCallback=None,
|
|
@@ -548,9 +604,12 @@ class GrowthBook(object):
|
|
|
548
604
|
|
|
549
605
|
self._qaMode = qa_mode or qaMode
|
|
550
606
|
self._trackingCallback = on_experiment_viewed or trackingCallback
|
|
607
|
+
self._featureUsageCallback = on_feature_usage
|
|
551
608
|
|
|
552
609
|
self._streaming = streaming
|
|
553
610
|
self._streaming_timeout = streaming_connection_timeout
|
|
611
|
+
self._stale_while_revalidate = stale_while_revalidate
|
|
612
|
+
self._stale_ttl = stale_ttl
|
|
554
613
|
|
|
555
614
|
# Deprecated args
|
|
556
615
|
self._user = user
|
|
@@ -603,6 +662,13 @@ class GrowthBook(object):
|
|
|
603
662
|
if self._streaming:
|
|
604
663
|
self.load_features()
|
|
605
664
|
self.startAutoRefresh()
|
|
665
|
+
elif self._stale_while_revalidate and self._client_key:
|
|
666
|
+
# Start background refresh task for stale-while-revalidate
|
|
667
|
+
self.load_features() # Initial load
|
|
668
|
+
feature_repo.start_background_refresh(
|
|
669
|
+
self._api_host, self._client_key, self._decryption_key,
|
|
670
|
+
self._cache_ttl, self._stale_ttl
|
|
671
|
+
)
|
|
606
672
|
|
|
607
673
|
def _on_feature_update(self, features_data: Dict) -> None:
|
|
608
674
|
"""Callback to handle automatic feature updates from FeatureRepository"""
|
|
@@ -739,6 +805,13 @@ class GrowthBook(object):
|
|
|
739
805
|
except Exception as e:
|
|
740
806
|
logger.warning(f"Error stopping auto refresh during destroy: {e}")
|
|
741
807
|
|
|
808
|
+
try:
|
|
809
|
+
# Stop background refresh operations
|
|
810
|
+
if self._stale_while_revalidate and self._client_key:
|
|
811
|
+
feature_repo.stop_background_refresh()
|
|
812
|
+
except Exception as e:
|
|
813
|
+
logger.warning(f"Error stopping background refresh during destroy: {e}")
|
|
814
|
+
|
|
742
815
|
try:
|
|
743
816
|
# Clean up feature update callback
|
|
744
817
|
if self._client_key:
|
|
@@ -752,6 +825,7 @@ class GrowthBook(object):
|
|
|
752
825
|
self._tracked.clear()
|
|
753
826
|
self._assigned.clear()
|
|
754
827
|
self._trackingCallback = None
|
|
828
|
+
self._featureUsageCallback = None
|
|
755
829
|
self._forcedVariations.clear()
|
|
756
830
|
self._overrides.clear()
|
|
757
831
|
self._groups.clear()
|
|
@@ -790,8 +864,8 @@ class GrowthBook(object):
|
|
|
790
864
|
def _ensure_fresh_features(self) -> None:
|
|
791
865
|
"""Lazy refresh: Check cache expiry and refresh if needed, but only if client_key is provided"""
|
|
792
866
|
|
|
793
|
-
if self._streaming or not self._client_key:
|
|
794
|
-
return # Skip cache checks - SSE
|
|
867
|
+
if self._streaming or self._stale_while_revalidate or not self._client_key:
|
|
868
|
+
return # Skip cache checks - SSE or background refresh handles freshness
|
|
795
869
|
|
|
796
870
|
try:
|
|
797
871
|
self.load_features()
|
|
@@ -815,11 +889,18 @@ class GrowthBook(object):
|
|
|
815
889
|
)
|
|
816
890
|
|
|
817
891
|
def eval_feature(self, key: str) -> FeatureResult:
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
892
|
+
result = core_eval_feature(key=key,
|
|
893
|
+
evalContext=self._get_eval_context(),
|
|
894
|
+
callback_subscription=self._fireSubscriptions,
|
|
895
|
+
tracking_cb=self._track
|
|
896
|
+
)
|
|
897
|
+
# Call feature usage callback if provided
|
|
898
|
+
if self._featureUsageCallback:
|
|
899
|
+
try:
|
|
900
|
+
self._featureUsageCallback(key, result)
|
|
901
|
+
except Exception:
|
|
902
|
+
pass
|
|
903
|
+
return result
|
|
823
904
|
|
|
824
905
|
# @deprecated, use get_all_results
|
|
825
906
|
def getAllResults(self):
|
|
@@ -500,24 +500,50 @@ class GrowthBookClient:
|
|
|
500
500
|
async with self._context_lock:
|
|
501
501
|
context = await self.create_evaluation_context(user_context)
|
|
502
502
|
result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
|
|
503
|
+
# Call feature usage callback if provided
|
|
504
|
+
if self.options.on_feature_usage:
|
|
505
|
+
try:
|
|
506
|
+
self.options.on_feature_usage(key, result)
|
|
507
|
+
except Exception:
|
|
508
|
+
logger.exception("Error in feature usage callback")
|
|
503
509
|
return result
|
|
504
510
|
|
|
505
511
|
async def is_on(self, key: str, user_context: UserContext) -> bool:
|
|
506
512
|
"""Check if a feature is enabled with proper async context management"""
|
|
507
513
|
async with self._context_lock:
|
|
508
514
|
context = await self.create_evaluation_context(user_context)
|
|
509
|
-
|
|
515
|
+
result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
|
|
516
|
+
# Call feature usage callback if provided
|
|
517
|
+
if self.options.on_feature_usage:
|
|
518
|
+
try:
|
|
519
|
+
self.options.on_feature_usage(key, result)
|
|
520
|
+
except Exception:
|
|
521
|
+
logger.exception("Error in feature usage callback")
|
|
522
|
+
return result.on
|
|
510
523
|
|
|
511
524
|
async def is_off(self, key: str, user_context: UserContext) -> bool:
|
|
512
525
|
"""Check if a feature is set to off with proper async context management"""
|
|
513
526
|
async with self._context_lock:
|
|
514
527
|
context = await self.create_evaluation_context(user_context)
|
|
515
|
-
|
|
528
|
+
result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
|
|
529
|
+
# Call feature usage callback if provided
|
|
530
|
+
if self.options.on_feature_usage:
|
|
531
|
+
try:
|
|
532
|
+
self.options.on_feature_usage(key, result)
|
|
533
|
+
except Exception:
|
|
534
|
+
logger.exception("Error in feature usage callback")
|
|
535
|
+
return result.off
|
|
516
536
|
|
|
517
537
|
async def get_feature_value(self, key: str, fallback: Any, user_context: UserContext) -> Any:
|
|
518
538
|
async with self._context_lock:
|
|
519
539
|
context = await self.create_evaluation_context(user_context)
|
|
520
540
|
result = core_eval_feature(key=key, evalContext=context, tracking_cb=self._track)
|
|
541
|
+
# Call feature usage callback if provided
|
|
542
|
+
if self.options.on_feature_usage:
|
|
543
|
+
try:
|
|
544
|
+
self.options.on_feature_usage(key, result)
|
|
545
|
+
except Exception:
|
|
546
|
+
logger.exception("Error in feature usage callback")
|
|
521
547
|
return result.value if result.value is not None else fallback
|
|
522
548
|
|
|
523
549
|
async def run(self, experiment: Experiment, user_context: UserContext) -> Result:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pytest
|
|
2
|
+
import pytest_asyncio
|
|
2
3
|
import asyncio
|
|
3
4
|
import os
|
|
4
5
|
import sys
|
|
@@ -22,7 +23,7 @@ def reset_singleton():
|
|
|
22
23
|
instance._stop_event.set()
|
|
23
24
|
SingletonMeta._instances.clear()
|
|
24
25
|
|
|
25
|
-
@
|
|
26
|
+
@pytest_asyncio.fixture(autouse=True)
|
|
26
27
|
async def cleanup_tasks():
|
|
27
28
|
"""Cleanup any pending tasks after each test."""
|
|
28
29
|
yield
|
|
@@ -215,6 +215,85 @@ def test_tracking():
|
|
|
215
215
|
gb.destroy()
|
|
216
216
|
|
|
217
217
|
|
|
218
|
+
def test_feature_usage_callback():
|
|
219
|
+
"""Test that feature usage callback is called correctly"""
|
|
220
|
+
calls = []
|
|
221
|
+
|
|
222
|
+
def feature_usage_cb(key, result):
|
|
223
|
+
calls.append([key, result])
|
|
224
|
+
|
|
225
|
+
gb = GrowthBook(
|
|
226
|
+
attributes={"id": "1"},
|
|
227
|
+
on_feature_usage=feature_usage_cb,
|
|
228
|
+
features={
|
|
229
|
+
"feature-1": Feature(defaultValue=True),
|
|
230
|
+
"feature-2": Feature(defaultValue=False),
|
|
231
|
+
"feature-3": Feature(
|
|
232
|
+
defaultValue="blue",
|
|
233
|
+
rules=[
|
|
234
|
+
FeatureRule(force="red", condition={"id": "1"})
|
|
235
|
+
]
|
|
236
|
+
),
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Test eval_feature
|
|
241
|
+
result1 = gb.eval_feature("feature-1")
|
|
242
|
+
assert len(calls) == 1
|
|
243
|
+
assert calls[0][0] == "feature-1"
|
|
244
|
+
assert calls[0][1].value is True
|
|
245
|
+
assert calls[0][1].source == "defaultValue"
|
|
246
|
+
|
|
247
|
+
# Test is_on
|
|
248
|
+
gb.is_on("feature-2")
|
|
249
|
+
assert len(calls) == 2
|
|
250
|
+
assert calls[1][0] == "feature-2"
|
|
251
|
+
assert calls[1][1].value is False
|
|
252
|
+
|
|
253
|
+
# Test get_feature_value
|
|
254
|
+
value = gb.get_feature_value("feature-3", "blue")
|
|
255
|
+
assert len(calls) == 3
|
|
256
|
+
assert calls[2][0] == "feature-3"
|
|
257
|
+
assert calls[2][1].value == "red"
|
|
258
|
+
assert value == "red"
|
|
259
|
+
|
|
260
|
+
# Test is_off
|
|
261
|
+
gb.is_off("feature-1")
|
|
262
|
+
assert len(calls) == 4
|
|
263
|
+
assert calls[3][0] == "feature-1"
|
|
264
|
+
|
|
265
|
+
# Calling same feature multiple times should trigger callback each time
|
|
266
|
+
gb.eval_feature("feature-1")
|
|
267
|
+
gb.eval_feature("feature-1")
|
|
268
|
+
assert len(calls) == 6
|
|
269
|
+
|
|
270
|
+
gb.destroy()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_feature_usage_callback_error_handling():
|
|
274
|
+
"""Test that feature usage callback errors are handled gracefully"""
|
|
275
|
+
|
|
276
|
+
def failing_callback(key, result):
|
|
277
|
+
raise Exception("Callback error")
|
|
278
|
+
|
|
279
|
+
gb = GrowthBook(
|
|
280
|
+
attributes={"id": "1"},
|
|
281
|
+
on_feature_usage=failing_callback,
|
|
282
|
+
features={
|
|
283
|
+
"feature-1": Feature(defaultValue=True),
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Should not raise an error even if callback fails
|
|
288
|
+
result = gb.eval_feature("feature-1")
|
|
289
|
+
assert result.value is True
|
|
290
|
+
|
|
291
|
+
# Should work with is_on as well
|
|
292
|
+
assert gb.is_on("feature-1") is True
|
|
293
|
+
|
|
294
|
+
gb.destroy()
|
|
295
|
+
|
|
296
|
+
|
|
218
297
|
def test_handles_weird_experiment_values():
|
|
219
298
|
gb = GrowthBook(attributes={"id": "1"})
|
|
220
299
|
|
|
@@ -1009,4 +1088,164 @@ def test_multiple_instances_get_updated_on_cache_expiry(mocker):
|
|
|
1009
1088
|
finally:
|
|
1010
1089
|
gb1.destroy()
|
|
1011
1090
|
gb2.destroy()
|
|
1091
|
+
feature_repo.clear_cache()
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def test_stale_while_revalidate_basic_functionality(mocker):
|
|
1095
|
+
"""Test basic stale-while-revalidate functionality"""
|
|
1096
|
+
# Mock responses - first call returns v1, subsequent calls return v2
|
|
1097
|
+
mock_responses = [
|
|
1098
|
+
{"features": {"test_feature": {"defaultValue": "v1"}}, "savedGroups": {}},
|
|
1099
|
+
{"features": {"test_feature": {"defaultValue": "v2"}}, "savedGroups": {}}
|
|
1100
|
+
]
|
|
1101
|
+
|
|
1102
|
+
call_count = 0
|
|
1103
|
+
def mock_fetch_features(api_host, client_key, decryption_key=""):
|
|
1104
|
+
nonlocal call_count
|
|
1105
|
+
response = mock_responses[min(call_count, len(mock_responses) - 1)]
|
|
1106
|
+
call_count += 1
|
|
1107
|
+
return response
|
|
1108
|
+
|
|
1109
|
+
# Clear cache and mock the fetch method
|
|
1110
|
+
feature_repo.clear_cache()
|
|
1111
|
+
m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features)
|
|
1112
|
+
|
|
1113
|
+
# Create GrowthBook instance with stale-while-revalidate enabled and short refresh interval
|
|
1114
|
+
gb = GrowthBook(
|
|
1115
|
+
api_host="https://cdn.growthbook.io",
|
|
1116
|
+
client_key="test-key",
|
|
1117
|
+
cache_ttl=10, # 10 second TTL
|
|
1118
|
+
stale_while_revalidate=True,
|
|
1119
|
+
stale_ttl=1 # 1 second refresh interval for testing
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
try:
|
|
1123
|
+
# Initial evaluation - should use initial loaded data
|
|
1124
|
+
assert gb.get_feature_value('test_feature', 'default') == "v1"
|
|
1125
|
+
assert call_count == 1 # Initial load
|
|
1126
|
+
|
|
1127
|
+
# Wait for background refresh to happen
|
|
1128
|
+
import time as time_module
|
|
1129
|
+
time_module.sleep(1.5) # Wait longer than refresh interval
|
|
1130
|
+
|
|
1131
|
+
# Should have triggered background refresh
|
|
1132
|
+
assert call_count >= 2
|
|
1133
|
+
|
|
1134
|
+
# Next evaluation should get updated data from background refresh
|
|
1135
|
+
assert gb.get_feature_value('test_feature', 'default') == "v2"
|
|
1136
|
+
|
|
1137
|
+
finally:
|
|
1138
|
+
gb.destroy()
|
|
1139
|
+
feature_repo.clear_cache()
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def test_stale_while_revalidate_starts_background_task(mocker):
|
|
1143
|
+
"""Test that stale-while-revalidate starts background refresh task"""
|
|
1144
|
+
mock_response = {"features": {"test_feature": {"defaultValue": "fresh"}}, "savedGroups": {}}
|
|
1145
|
+
|
|
1146
|
+
call_count = 0
|
|
1147
|
+
def mock_fetch_features(api_host, client_key, decryption_key=""):
|
|
1148
|
+
nonlocal call_count
|
|
1149
|
+
call_count += 1
|
|
1150
|
+
return mock_response
|
|
1151
|
+
|
|
1152
|
+
# Clear cache and mock the fetch method
|
|
1153
|
+
feature_repo.clear_cache()
|
|
1154
|
+
m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features)
|
|
1155
|
+
|
|
1156
|
+
# Create GrowthBook instance with stale-while-revalidate enabled
|
|
1157
|
+
gb = GrowthBook(
|
|
1158
|
+
api_host="https://cdn.growthbook.io",
|
|
1159
|
+
client_key="test-key",
|
|
1160
|
+
stale_while_revalidate=True,
|
|
1161
|
+
stale_ttl=5
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
try:
|
|
1165
|
+
# Should have started background refresh task
|
|
1166
|
+
assert feature_repo._refresh_thread is not None
|
|
1167
|
+
assert feature_repo._refresh_thread.is_alive()
|
|
1168
|
+
|
|
1169
|
+
# Initial evaluation should work
|
|
1170
|
+
assert gb.get_feature_value('test_feature', 'default') == "fresh"
|
|
1171
|
+
assert call_count == 1 # Initial load
|
|
1172
|
+
|
|
1173
|
+
finally:
|
|
1174
|
+
gb.destroy()
|
|
1175
|
+
feature_repo.clear_cache()
|
|
1176
|
+
|
|
1177
|
+
def test_stale_while_revalidate_disabled_fallback(mocker):
|
|
1178
|
+
"""Test that when stale_while_revalidate is disabled, it falls back to normal behavior"""
|
|
1179
|
+
mock_response = {"features": {"test_feature": {"defaultValue": "normal"}}, "savedGroups": {}}
|
|
1180
|
+
|
|
1181
|
+
call_count = 0
|
|
1182
|
+
def mock_fetch_features(api_host, client_key, decryption_key=""):
|
|
1183
|
+
nonlocal call_count
|
|
1184
|
+
call_count += 1
|
|
1185
|
+
return mock_response
|
|
1186
|
+
|
|
1187
|
+
# Clear cache and mock the fetch method
|
|
1188
|
+
feature_repo.clear_cache()
|
|
1189
|
+
m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features)
|
|
1190
|
+
|
|
1191
|
+
# Create GrowthBook instance with stale-while-revalidate disabled (default)
|
|
1192
|
+
gb = GrowthBook(
|
|
1193
|
+
api_host="https://cdn.growthbook.io",
|
|
1194
|
+
client_key="test-key",
|
|
1195
|
+
cache_ttl=1, # Short TTL
|
|
1196
|
+
stale_while_revalidate=False # Explicitly disabled
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
try:
|
|
1200
|
+
# Should NOT have started background refresh task
|
|
1201
|
+
assert feature_repo._refresh_thread is None
|
|
1202
|
+
|
|
1203
|
+
# Initial evaluation
|
|
1204
|
+
assert gb.get_feature_value('test_feature', 'default') == "normal"
|
|
1205
|
+
assert call_count == 1
|
|
1206
|
+
|
|
1207
|
+
# Manually expire the cache
|
|
1208
|
+
cache_key = "https://cdn.growthbook.io::test-key"
|
|
1209
|
+
if hasattr(feature_repo.cache, 'cache') and cache_key in feature_repo.cache.cache:
|
|
1210
|
+
feature_repo.cache.cache[cache_key].expires = time() - 10
|
|
1211
|
+
|
|
1212
|
+
# Next evaluation should fetch synchronously (normal behavior)
|
|
1213
|
+
assert gb.get_feature_value('test_feature', 'default') == "normal"
|
|
1214
|
+
assert call_count == 2 # Should have fetched again
|
|
1215
|
+
|
|
1216
|
+
finally:
|
|
1217
|
+
gb.destroy()
|
|
1218
|
+
feature_repo.clear_cache()
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def test_stale_while_revalidate_cleanup(mocker):
|
|
1222
|
+
"""Test that background refresh is properly cleaned up"""
|
|
1223
|
+
mock_response = {"features": {"test_feature": {"defaultValue": "test"}}, "savedGroups": {}}
|
|
1224
|
+
|
|
1225
|
+
# Mock the fetch method
|
|
1226
|
+
feature_repo.clear_cache()
|
|
1227
|
+
m = mocker.patch.object(feature_repo, '_fetch_features', return_value=mock_response)
|
|
1228
|
+
|
|
1229
|
+
# Create GrowthBook instance with stale-while-revalidate enabled
|
|
1230
|
+
gb = GrowthBook(
|
|
1231
|
+
api_host="https://cdn.growthbook.io",
|
|
1232
|
+
client_key="test-key",
|
|
1233
|
+
stale_while_revalidate=True
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
try:
|
|
1237
|
+
# Should have started background refresh task
|
|
1238
|
+
assert feature_repo._refresh_thread is not None
|
|
1239
|
+
assert feature_repo._refresh_thread.is_alive()
|
|
1240
|
+
|
|
1241
|
+
# Destroy should clean up the background task
|
|
1242
|
+
gb.destroy()
|
|
1243
|
+
|
|
1244
|
+
# Background task should be stopped
|
|
1245
|
+
assert feature_repo._refresh_thread is None or not feature_repo._refresh_thread.is_alive()
|
|
1246
|
+
|
|
1247
|
+
finally:
|
|
1248
|
+
# Ensure cleanup even if test fails
|
|
1249
|
+
if feature_repo._refresh_thread:
|
|
1250
|
+
feature_repo.stop_background_refresh()
|
|
1012
1251
|
feature_repo.clear_cache()
|
|
@@ -767,5 +767,129 @@ async def test_handles_tracking_errors():
|
|
|
767
767
|
calls = getMockedTrackingCalls()
|
|
768
768
|
assert len(calls) == 1, "Expected exactly 1 tracking call"
|
|
769
769
|
|
|
770
|
+
finally:
|
|
771
|
+
await client.close()
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
@pytest.mark.asyncio
|
|
775
|
+
async def test_feature_usage_callback():
|
|
776
|
+
"""Test that feature usage callback is called correctly"""
|
|
777
|
+
calls = []
|
|
778
|
+
|
|
779
|
+
def feature_usage_cb(key, result):
|
|
780
|
+
calls.append([key, result])
|
|
781
|
+
|
|
782
|
+
client = GrowthBookClient(Options(
|
|
783
|
+
api_host="https://localhost.growthbook.io",
|
|
784
|
+
client_key="test-key",
|
|
785
|
+
enabled=True,
|
|
786
|
+
on_feature_usage=feature_usage_cb
|
|
787
|
+
))
|
|
788
|
+
|
|
789
|
+
user_context = UserContext(attributes={"id": "1"})
|
|
790
|
+
|
|
791
|
+
try:
|
|
792
|
+
# Set up mocks for feature repository
|
|
793
|
+
mock_features = {
|
|
794
|
+
"features": {
|
|
795
|
+
"feature-1": {"defaultValue": True},
|
|
796
|
+
"feature-2": {"defaultValue": False},
|
|
797
|
+
"feature-3": {
|
|
798
|
+
"defaultValue": "blue",
|
|
799
|
+
"rules": [
|
|
800
|
+
{"force": "red", "condition": {"id": "1"}}
|
|
801
|
+
]
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
"savedGroups": {}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
with patch('growthbook.FeatureRepository.load_features_async',
|
|
808
|
+
new_callable=AsyncMock, return_value=mock_features), \
|
|
809
|
+
patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh',
|
|
810
|
+
new_callable=AsyncMock), \
|
|
811
|
+
patch('growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh',
|
|
812
|
+
new_callable=AsyncMock):
|
|
813
|
+
|
|
814
|
+
# Initialize client
|
|
815
|
+
await client.initialize()
|
|
816
|
+
|
|
817
|
+
# Test eval_feature
|
|
818
|
+
result1 = await client.eval_feature("feature-1", user_context)
|
|
819
|
+
assert len(calls) == 1
|
|
820
|
+
assert calls[0][0] == "feature-1"
|
|
821
|
+
assert calls[0][1].value is True
|
|
822
|
+
assert calls[0][1].source == "defaultValue"
|
|
823
|
+
|
|
824
|
+
# Test is_on
|
|
825
|
+
await client.is_on("feature-2", user_context)
|
|
826
|
+
assert len(calls) == 2
|
|
827
|
+
assert calls[1][0] == "feature-2"
|
|
828
|
+
assert calls[1][1].value is False
|
|
829
|
+
|
|
830
|
+
# Test get_feature_value
|
|
831
|
+
value = await client.get_feature_value("feature-3", "blue", user_context)
|
|
832
|
+
assert len(calls) == 3
|
|
833
|
+
assert calls[2][0] == "feature-3"
|
|
834
|
+
assert calls[2][1].value == "red"
|
|
835
|
+
assert value == "red"
|
|
836
|
+
|
|
837
|
+
# Test is_off
|
|
838
|
+
await client.is_off("feature-1", user_context)
|
|
839
|
+
assert len(calls) == 4
|
|
840
|
+
assert calls[3][0] == "feature-1"
|
|
841
|
+
|
|
842
|
+
# Calling same feature multiple times should trigger callback each time
|
|
843
|
+
await client.eval_feature("feature-1", user_context)
|
|
844
|
+
await client.eval_feature("feature-1", user_context)
|
|
845
|
+
assert len(calls) == 6
|
|
846
|
+
|
|
847
|
+
finally:
|
|
848
|
+
await client.close()
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
@pytest.mark.asyncio
|
|
852
|
+
async def test_feature_usage_callback_error_handling():
|
|
853
|
+
"""Test that feature usage callback errors are handled gracefully"""
|
|
854
|
+
|
|
855
|
+
def failing_callback(key, result):
|
|
856
|
+
raise Exception("Callback error")
|
|
857
|
+
|
|
858
|
+
client = GrowthBookClient(Options(
|
|
859
|
+
api_host="https://localhost.growthbook.io",
|
|
860
|
+
client_key="test-key",
|
|
861
|
+
enabled=True,
|
|
862
|
+
on_feature_usage=failing_callback
|
|
863
|
+
))
|
|
864
|
+
|
|
865
|
+
user_context = UserContext(attributes={"id": "1"})
|
|
866
|
+
|
|
867
|
+
try:
|
|
868
|
+
# Set up mocks for feature repository
|
|
869
|
+
mock_features = {
|
|
870
|
+
"features": {
|
|
871
|
+
"feature-1": {"defaultValue": True},
|
|
872
|
+
},
|
|
873
|
+
"savedGroups": {}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
with patch('growthbook.FeatureRepository.load_features_async',
|
|
877
|
+
new_callable=AsyncMock, return_value=mock_features), \
|
|
878
|
+
patch('growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh',
|
|
879
|
+
new_callable=AsyncMock), \
|
|
880
|
+
patch('growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh',
|
|
881
|
+
new_callable=AsyncMock):
|
|
882
|
+
|
|
883
|
+
# Initialize client
|
|
884
|
+
await client.initialize()
|
|
885
|
+
|
|
886
|
+
# Should not raise an error even if callback fails
|
|
887
|
+
result = await client.eval_feature("feature-1", user_context)
|
|
888
|
+
assert result.value is True
|
|
889
|
+
|
|
890
|
+
# Should work with is_on as well
|
|
891
|
+
is_on = await client.is_on("feature-1", user_context)
|
|
892
|
+
assert is_on is True
|
|
893
|
+
|
|
770
894
|
finally:
|
|
771
895
|
await client.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|