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.
Files changed (26) hide show
  1. {growthbook-1.4.3/growthbook.egg-info → growthbook-1.4.5}/PKG-INFO +1 -1
  2. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/__init__.py +1 -1
  3. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/common_types.py +1 -0
  4. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/growthbook.py +89 -8
  5. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/growthbook_client.py +28 -2
  6. {growthbook-1.4.3 → growthbook-1.4.5/growthbook.egg-info}/PKG-INFO +1 -1
  7. {growthbook-1.4.3 → growthbook-1.4.5}/setup.cfg +1 -1
  8. {growthbook-1.4.3 → growthbook-1.4.5}/tests/conftest.py +2 -1
  9. {growthbook-1.4.3 → growthbook-1.4.5}/tests/test_growthbook.py +239 -0
  10. {growthbook-1.4.3 → growthbook-1.4.5}/tests/test_growthbook_client.py +124 -0
  11. {growthbook-1.4.3 → growthbook-1.4.5}/LICENSE +0 -0
  12. {growthbook-1.4.3 → growthbook-1.4.5}/MANIFEST.in +0 -0
  13. {growthbook-1.4.3 → growthbook-1.4.5}/README.md +0 -0
  14. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/core.py +0 -0
  15. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/plugins/__init__.py +0 -0
  16. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/plugins/base.py +0 -0
  17. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/plugins/growthbook_tracking.py +0 -0
  18. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/plugins/request_context.py +0 -0
  19. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook/py.typed +0 -0
  20. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook.egg-info/SOURCES.txt +0 -0
  21. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook.egg-info/dependency_links.txt +0 -0
  22. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook.egg-info/requires.txt +0 -0
  23. {growthbook-1.4.3 → growthbook-1.4.5}/growthbook.egg-info/top_level.txt +0 -0
  24. {growthbook-1.4.3 → growthbook-1.4.5}/pyproject.toml +0 -0
  25. {growthbook-1.4.3 → growthbook-1.4.5}/setup.py +0 -0
  26. {growthbook-1.4.3 → growthbook-1.4.5}/tests/test_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.3
3
+ Version: 1.4.5
4
4
  Summary: Powerful Feature flagging and A/B testing for Python apps
5
5
  Home-page: https://github.com/growthbook/growthbook-python
6
6
  Author: GrowthBook
@@ -18,5 +18,5 @@ from .plugins import (
18
18
  )
19
19
 
20
20
  # x-release-please-start-version
21
- __version__ = "1.4.3"
21
+ __version__ = "1.4.5"
22
22
  # x-release-please-end
@@ -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
- self.cache[key] = CacheEntry(value, ttl)
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 handles freshness for streaming users
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
- return core_eval_feature(key=key,
819
- evalContext=self._get_eval_context(),
820
- callback_subscription=self._fireSubscriptions,
821
- tracking_cb=self._track
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
- return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).on
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
- return core_eval_feature(key=key, evalContext=context, tracking_cb=self._track).off
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.3
3
+ Version: 1.4.5
4
4
  Summary: Powerful Feature flagging and A/B testing for Python apps
5
5
  Home-page: https://github.com/growthbook/growthbook-python
6
6
  Author: GrowthBook
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 1.4.3
2
+ current_version = 1.4.5
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -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
- @pytest.fixture(autouse=True)
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