growthbook 1.4.2__tar.gz → 1.4.4__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.2/growthbook.egg-info → growthbook-1.4.4}/PKG-INFO +1 -1
  2. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/__init__.py +1 -1
  3. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/growthbook.py +251 -76
  4. {growthbook-1.4.2 → growthbook-1.4.4/growthbook.egg-info}/PKG-INFO +1 -1
  5. {growthbook-1.4.2 → growthbook-1.4.4}/setup.cfg +1 -1
  6. {growthbook-1.4.2 → growthbook-1.4.4}/tests/test_growthbook.py +160 -0
  7. {growthbook-1.4.2 → growthbook-1.4.4}/LICENSE +0 -0
  8. {growthbook-1.4.2 → growthbook-1.4.4}/MANIFEST.in +0 -0
  9. {growthbook-1.4.2 → growthbook-1.4.4}/README.md +0 -0
  10. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/common_types.py +0 -0
  11. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/core.py +0 -0
  12. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/growthbook_client.py +0 -0
  13. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/plugins/__init__.py +0 -0
  14. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/plugins/base.py +0 -0
  15. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/plugins/growthbook_tracking.py +0 -0
  16. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/plugins/request_context.py +0 -0
  17. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/py.typed +0 -0
  18. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook.egg-info/SOURCES.txt +0 -0
  19. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook.egg-info/dependency_links.txt +0 -0
  20. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook.egg-info/requires.txt +0 -0
  21. {growthbook-1.4.2 → growthbook-1.4.4}/growthbook.egg-info/top_level.txt +0 -0
  22. {growthbook-1.4.2 → growthbook-1.4.4}/pyproject.toml +0 -0
  23. {growthbook-1.4.2 → growthbook-1.4.4}/setup.py +0 -0
  24. {growthbook-1.4.2 → growthbook-1.4.4}/tests/conftest.py +0 -0
  25. {growthbook-1.4.2 → growthbook-1.4.4}/tests/test_growthbook_client.py +0 -0
  26. {growthbook-1.4.2 → growthbook-1.4.4}/tests/test_plugins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.2
3
+ Version: 1.4.4
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.2"
21
+ __version__ = "1.4.4"
22
22
  # x-release-please-end
@@ -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()
@@ -151,17 +152,32 @@ class SSEClient:
151
152
  self._sse_thread = threading.Thread(target=self._run_sse_channel)
152
153
  self._sse_thread.start()
153
154
 
154
- def disconnect(self):
155
+ def disconnect(self, timeout=10):
156
+ """Gracefully disconnect with timeout"""
157
+ logger.debug("Initiating SSE client disconnect")
155
158
  self.is_running = False
159
+
156
160
  if self._loop and self._loop.is_running():
157
- future = asyncio.run_coroutine_threadsafe(self._stop_session(), self._loop)
161
+ future = asyncio.run_coroutine_threadsafe(self._stop_session(timeout), self._loop)
158
162
  try:
159
- future.result()
163
+ # Wait with timeout for clean shutdown
164
+ future.result(timeout=timeout)
165
+ logger.debug("SSE session stopped cleanly")
160
166
  except Exception as e:
161
- logger.error(f"Streaming disconnect error: {e}")
167
+ logger.warning(f"Error during SSE disconnect: {e}")
168
+ # Force close the loop if clean shutdown failed
169
+ if self._loop and self._loop.is_running():
170
+ try:
171
+ self._loop.call_soon_threadsafe(self._loop.stop)
172
+ except Exception:
173
+ pass
162
174
 
163
175
  if self._sse_thread:
164
- self._sse_thread.join(timeout=5)
176
+ self._sse_thread.join(timeout=timeout)
177
+ if self._sse_thread.is_alive():
178
+ logger.warning("SSE thread did not terminate gracefully within timeout")
179
+ else:
180
+ logger.debug("SSE thread terminated")
165
181
 
166
182
  logger.debug("Streaming session disconnected")
167
183
 
@@ -172,52 +188,84 @@ class SSEClient:
172
188
  async def _init_session(self):
173
189
  url = self._get_sse_url(self.api_host, self.client_key)
174
190
 
175
- while self.is_running:
176
- try:
177
- async with aiohttp.ClientSession(headers=self.headers,
178
- timeout=aiohttp.ClientTimeout(connect=self.timeout)) as session:
179
- self._sse_session = session
180
-
181
- async with session.get(url) as response:
182
- response.raise_for_status()
183
- await self._process_response(response)
184
- except ClientResponseError as e:
185
- logger.error(f"Streaming error, closing connection: {e.status} {e.message}")
186
- self.is_running = False
187
- break
188
- except (ClientConnectorError, ClientPayloadError) as e:
189
- logger.error(f"Streaming error: {e}")
190
- if not self.is_running:
191
+ try:
192
+ while self.is_running:
193
+ try:
194
+ async with aiohttp.ClientSession(headers=self.headers,
195
+ timeout=aiohttp.ClientTimeout(connect=self.timeout)) as session:
196
+ self._sse_session = session
197
+
198
+ async with session.get(url) as response:
199
+ response.raise_for_status()
200
+ await self._process_response(response)
201
+ except ClientResponseError as e:
202
+ logger.error(f"Streaming error, closing connection: {e.status} {e.message}")
203
+ self.is_running = False
204
+ break
205
+ except (ClientConnectorError, ClientPayloadError) as e:
206
+ logger.error(f"Streaming error: {e}")
207
+ if not self.is_running:
208
+ break
209
+ await self._wait_for_reconnect()
210
+ except TimeoutError:
211
+ logger.warning(f"Streaming connection timed out after {self.timeout} seconds.")
212
+ if not self.is_running:
213
+ break
214
+ await self._wait_for_reconnect()
215
+ except asyncio.CancelledError:
216
+ logger.debug("SSE session cancelled")
191
217
  break
192
- await self._wait_for_reconnect()
193
- except TimeoutError:
194
- logger.warning(f"Streaming connection timed out after {self.timeout} seconds.")
195
- await self._wait_for_reconnect()
196
- except asyncio.CancelledError:
197
- logger.debug("Streaming was cancelled.")
198
- break
199
- finally:
200
- await self._close_session()
218
+ finally:
219
+ await self._close_session()
220
+ except asyncio.CancelledError:
221
+ logger.debug("SSE _init_session cancelled")
222
+ pass
223
+ finally:
224
+ # Ensure session is closed on any exit
225
+ await self._close_session()
201
226
 
202
227
  async def _process_response(self, response):
203
228
  event_data = {}
204
- async for line in response.content:
205
- decoded_line = line.decode('utf-8').strip()
206
- if decoded_line.startswith("event:"):
207
- event_data['type'] = decoded_line[len("event:"):].strip()
208
- elif decoded_line.startswith("data:"):
209
- event_data['data'] = event_data.get('data', '') + f"\n{decoded_line[len('data:'):].strip()}"
210
- elif not decoded_line:
211
- if 'type' in event_data and 'data' in event_data:
229
+ try:
230
+ async for line in response.content:
231
+ # Check for cancellation before processing each line
232
+ if not self.is_running:
233
+ logger.debug("SSE processing stopped - is_running is False")
234
+ break
235
+
236
+ decoded_line = line.decode('utf-8').strip()
237
+ if decoded_line.startswith("event:"):
238
+ event_data['type'] = decoded_line[len("event:"):].strip()
239
+ elif decoded_line.startswith("data:"):
240
+ event_data['data'] = event_data.get('data', '') + f"\n{decoded_line[len('data:'):].strip()}"
241
+ elif not decoded_line:
242
+ if 'type' in event_data and 'data' in event_data:
243
+ try:
244
+ self.on_event(event_data)
245
+ except Exception as e:
246
+ logger.warning(f"Error in event handler: {e}")
247
+ event_data = {}
248
+
249
+ # Process any remaining event data
250
+ if 'type' in event_data and 'data' in event_data:
251
+ try:
212
252
  self.on_event(event_data)
213
- event_data = {}
214
-
215
- if 'type' in event_data and 'data' in event_data:
216
- self.on_event(event_data)
253
+ except Exception as e:
254
+ logger.warning(f"Error in final event handler: {e}")
255
+ except asyncio.CancelledError:
256
+ logger.debug("SSE response processing cancelled")
257
+ raise
258
+ except Exception as e:
259
+ logger.warning(f"Error processing SSE response: {e}")
260
+ raise
217
261
 
218
262
  async def _wait_for_reconnect(self):
219
263
  logger.info(f"Attempting to reconnect streaming in {self.reconnect_delay} seconds")
220
- await asyncio.sleep(self.reconnect_delay)
264
+ try:
265
+ await asyncio.sleep(self.reconnect_delay)
266
+ except asyncio.CancelledError:
267
+ logger.debug("Reconnect wait cancelled")
268
+ raise
221
269
 
222
270
  async def _close_session(self):
223
271
  if self._sse_session:
@@ -235,18 +283,44 @@ class SSEClient:
235
283
  self._loop.run_until_complete(self._loop.shutdown_asyncgens())
236
284
  self._loop.close()
237
285
 
238
- async def _stop_session(self):
239
- if self._sse_session:
240
- await self._sse_session.close()
286
+ async def _stop_session(self, timeout=10):
287
+ """Stop the SSE session and cancel all tasks with timeout"""
288
+ logger.debug("Stopping SSE session")
289
+
290
+ # Close the session first
291
+ if self._sse_session and not self._sse_session.closed:
292
+ try:
293
+ await self._sse_session.close()
294
+ logger.debug("SSE session closed")
295
+ except Exception as e:
296
+ logger.warning(f"Error closing SSE session: {e}")
241
297
 
298
+ # Cancel all tasks in this loop
242
299
  if self._loop and self._loop.is_running():
243
- tasks = [task for task in asyncio.all_tasks(self._loop) if not task.done()]
244
- for task in tasks:
245
- task.cancel()
246
- try:
247
- await task
248
- except asyncio.CancelledError:
249
- pass
300
+ try:
301
+ # Get all tasks for this specific loop
302
+ tasks = [task for task in asyncio.all_tasks(self._loop)
303
+ if not task.done() and task is not asyncio.current_task(self._loop)]
304
+
305
+ if tasks:
306
+ logger.debug(f"Cancelling {len(tasks)} SSE tasks")
307
+ # Cancel all tasks
308
+ for task in tasks:
309
+ task.cancel()
310
+
311
+ # Wait for tasks to complete with timeout
312
+ try:
313
+ await asyncio.wait_for(
314
+ asyncio.gather(*tasks, return_exceptions=True),
315
+ timeout=timeout
316
+ )
317
+ logger.debug("All SSE tasks cancelled successfully")
318
+ except asyncio.TimeoutError:
319
+ logger.warning("Some SSE tasks did not cancel within timeout")
320
+ except Exception as e:
321
+ logger.warning(f"Error during task cancellation: {e}")
322
+ except Exception as e:
323
+ logger.warning(f"Error during SSE task cleanup: {e}")
250
324
 
251
325
  class FeatureRepository(object):
252
326
  def __init__(self) -> None:
@@ -254,6 +328,11 @@ class FeatureRepository(object):
254
328
  self.http: Optional[PoolManager] = None
255
329
  self.sse_client: Optional[SSEClient] = None
256
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()
257
336
 
258
337
  def set_cache(self, cache: AbstractFeatureCache) -> None:
259
338
  self.cache = cache
@@ -302,6 +381,7 @@ class FeatureRepository(object):
302
381
  return res
303
382
  return cached
304
383
 
384
+
305
385
  async def load_features_async(
306
386
  self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600
307
387
  ) -> Optional[Dict]:
@@ -415,8 +495,57 @@ class FeatureRepository(object):
415
495
  self.sse_client = self.sse_client or SSEClient(api_host=api_host, client_key=client_key, on_event=cb, timeout=streaming_timeout)
416
496
  self.sse_client.connect()
417
497
 
418
- def stopAutoRefresh(self):
419
- self.sse_client.disconnect()
498
+ def stopAutoRefresh(self, timeout=10):
499
+ """Stop auto refresh with timeout"""
500
+ if self.sse_client:
501
+ self.sse_client.disconnect(timeout=timeout)
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")
420
549
 
421
550
  @staticmethod
422
551
  def _get_features_url(api_host: str, client_key: str) -> str:
@@ -446,6 +575,8 @@ class GrowthBook(object):
446
575
  savedGroups: dict = {},
447
576
  streaming: bool = False,
448
577
  streaming_connection_timeout: int = 30,
578
+ stale_while_revalidate: bool = False,
579
+ stale_ttl: int = 300, # 5 minutes default
449
580
  plugins: List = None,
450
581
  # Deprecated args
451
582
  trackingCallback=None,
@@ -475,6 +606,8 @@ class GrowthBook(object):
475
606
 
476
607
  self._streaming = streaming
477
608
  self._streaming_timeout = streaming_connection_timeout
609
+ self._stale_while_revalidate = stale_while_revalidate
610
+ self._stale_ttl = stale_ttl
478
611
 
479
612
  # Deprecated args
480
613
  self._user = user
@@ -527,6 +660,13 @@ class GrowthBook(object):
527
660
  if self._streaming:
528
661
  self.load_features()
529
662
  self.startAutoRefresh()
663
+ elif self._stale_while_revalidate and self._client_key:
664
+ # Start background refresh task for stale-while-revalidate
665
+ self.load_features() # Initial load
666
+ feature_repo.start_background_refresh(
667
+ self._api_host, self._client_key, self._decryption_key,
668
+ self._cache_ttl, self._stale_ttl
669
+ )
530
670
 
531
671
  def _on_feature_update(self, features_data: Dict) -> None:
532
672
  """Callback to handle automatic feature updates from FeatureRepository"""
@@ -595,8 +735,15 @@ class GrowthBook(object):
595
735
  streaming_timeout=self._streaming_timeout
596
736
  )
597
737
 
598
- def stopAutoRefresh(self):
599
- feature_repo.stopAutoRefresh()
738
+ def stopAutoRefresh(self, timeout=10):
739
+ """Stop auto refresh with timeout"""
740
+ try:
741
+ if hasattr(feature_repo, 'sse_client') and feature_repo.sse_client:
742
+ feature_repo.sse_client.disconnect(timeout=timeout)
743
+ else:
744
+ feature_repo.stopAutoRefresh()
745
+ except Exception as e:
746
+ logger.warning(f"Error stopping auto refresh: {e}")
600
747
 
601
748
  # @deprecated, use set_features
602
749
  def setFeatures(self, features: dict) -> None:
@@ -639,23 +786,51 @@ class GrowthBook(object):
639
786
  def get_attributes(self) -> dict:
640
787
  return self._attributes
641
788
 
642
- def destroy(self) -> None:
643
- # Clean up plugins first
644
- self._cleanup_plugins()
789
+ def destroy(self, timeout=10) -> None:
790
+ """Gracefully destroy the GrowthBook instance"""
791
+ logger.debug("Starting GrowthBook destroy process")
645
792
 
646
- # Clean up feature update callback
647
- if self._client_key:
648
- feature_repo.remove_feature_update_callback(self._on_feature_update)
649
-
650
- self._subscriptions.clear()
651
- self._tracked.clear()
652
- self._assigned.clear()
653
- self._trackingCallback = None
654
- self._forcedVariations.clear()
655
- self._overrides.clear()
656
- self._groups.clear()
657
- self._attributes.clear()
658
- self._features.clear()
793
+ try:
794
+ # Clean up plugins
795
+ logger.debug("Cleaning up plugins")
796
+ self._cleanup_plugins()
797
+ except Exception as e:
798
+ logger.warning(f"Error cleaning up plugins: {e}")
799
+
800
+ try:
801
+ logger.debug("Stopping auto refresh during destroy")
802
+ self.stopAutoRefresh(timeout=timeout)
803
+ except Exception as e:
804
+ logger.warning(f"Error stopping auto refresh during destroy: {e}")
805
+
806
+ try:
807
+ # Stop background refresh operations
808
+ if self._stale_while_revalidate and self._client_key:
809
+ feature_repo.stop_background_refresh()
810
+ except Exception as e:
811
+ logger.warning(f"Error stopping background refresh during destroy: {e}")
812
+
813
+ try:
814
+ # Clean up feature update callback
815
+ if self._client_key:
816
+ feature_repo.remove_feature_update_callback(self._on_feature_update)
817
+ except Exception as e:
818
+ logger.warning(f"Error removing feature update callback: {e}")
819
+
820
+ # Clear all internal state
821
+ try:
822
+ self._subscriptions.clear()
823
+ self._tracked.clear()
824
+ self._assigned.clear()
825
+ self._trackingCallback = None
826
+ self._forcedVariations.clear()
827
+ self._overrides.clear()
828
+ self._groups.clear()
829
+ self._attributes.clear()
830
+ self._features.clear()
831
+ logger.debug("GrowthBook instance destroyed successfully")
832
+ except Exception as e:
833
+ logger.warning(f"Error clearing internal state: {e}")
659
834
 
660
835
  # @deprecated, use is_on
661
836
  def isOn(self, key: str) -> bool:
@@ -686,8 +861,8 @@ class GrowthBook(object):
686
861
  def _ensure_fresh_features(self) -> None:
687
862
  """Lazy refresh: Check cache expiry and refresh if needed, but only if client_key is provided"""
688
863
 
689
- if self._streaming or not self._client_key:
690
- return # Skip cache checks - SSE handles freshness for streaming users
864
+ if self._streaming or self._stale_while_revalidate or not self._client_key:
865
+ return # Skip cache checks - SSE or background refresh handles freshness
691
866
 
692
867
  try:
693
868
  self.load_features()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: growthbook
3
- Version: 1.4.2
3
+ Version: 1.4.4
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.2
2
+ current_version = 1.4.4
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -1009,4 +1009,164 @@ def test_multiple_instances_get_updated_on_cache_expiry(mocker):
1009
1009
  finally:
1010
1010
  gb1.destroy()
1011
1011
  gb2.destroy()
1012
+ feature_repo.clear_cache()
1013
+
1014
+
1015
+ def test_stale_while_revalidate_basic_functionality(mocker):
1016
+ """Test basic stale-while-revalidate functionality"""
1017
+ # Mock responses - first call returns v1, subsequent calls return v2
1018
+ mock_responses = [
1019
+ {"features": {"test_feature": {"defaultValue": "v1"}}, "savedGroups": {}},
1020
+ {"features": {"test_feature": {"defaultValue": "v2"}}, "savedGroups": {}}
1021
+ ]
1022
+
1023
+ call_count = 0
1024
+ def mock_fetch_features(api_host, client_key, decryption_key=""):
1025
+ nonlocal call_count
1026
+ response = mock_responses[min(call_count, len(mock_responses) - 1)]
1027
+ call_count += 1
1028
+ return response
1029
+
1030
+ # Clear cache and mock the fetch method
1031
+ feature_repo.clear_cache()
1032
+ m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features)
1033
+
1034
+ # Create GrowthBook instance with stale-while-revalidate enabled and short refresh interval
1035
+ gb = GrowthBook(
1036
+ api_host="https://cdn.growthbook.io",
1037
+ client_key="test-key",
1038
+ cache_ttl=10, # 10 second TTL
1039
+ stale_while_revalidate=True,
1040
+ stale_ttl=1 # 1 second refresh interval for testing
1041
+ )
1042
+
1043
+ try:
1044
+ # Initial evaluation - should use initial loaded data
1045
+ assert gb.get_feature_value('test_feature', 'default') == "v1"
1046
+ assert call_count == 1 # Initial load
1047
+
1048
+ # Wait for background refresh to happen
1049
+ import time as time_module
1050
+ time_module.sleep(1.5) # Wait longer than refresh interval
1051
+
1052
+ # Should have triggered background refresh
1053
+ assert call_count >= 2
1054
+
1055
+ # Next evaluation should get updated data from background refresh
1056
+ assert gb.get_feature_value('test_feature', 'default') == "v2"
1057
+
1058
+ finally:
1059
+ gb.destroy()
1060
+ feature_repo.clear_cache()
1061
+
1062
+
1063
+ def test_stale_while_revalidate_starts_background_task(mocker):
1064
+ """Test that stale-while-revalidate starts background refresh task"""
1065
+ mock_response = {"features": {"test_feature": {"defaultValue": "fresh"}}, "savedGroups": {}}
1066
+
1067
+ call_count = 0
1068
+ def mock_fetch_features(api_host, client_key, decryption_key=""):
1069
+ nonlocal call_count
1070
+ call_count += 1
1071
+ return mock_response
1072
+
1073
+ # Clear cache and mock the fetch method
1074
+ feature_repo.clear_cache()
1075
+ m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features)
1076
+
1077
+ # Create GrowthBook instance with stale-while-revalidate enabled
1078
+ gb = GrowthBook(
1079
+ api_host="https://cdn.growthbook.io",
1080
+ client_key="test-key",
1081
+ stale_while_revalidate=True,
1082
+ stale_ttl=5
1083
+ )
1084
+
1085
+ try:
1086
+ # Should have started background refresh task
1087
+ assert feature_repo._refresh_thread is not None
1088
+ assert feature_repo._refresh_thread.is_alive()
1089
+
1090
+ # Initial evaluation should work
1091
+ assert gb.get_feature_value('test_feature', 'default') == "fresh"
1092
+ assert call_count == 1 # Initial load
1093
+
1094
+ finally:
1095
+ gb.destroy()
1096
+ feature_repo.clear_cache()
1097
+
1098
+ def test_stale_while_revalidate_disabled_fallback(mocker):
1099
+ """Test that when stale_while_revalidate is disabled, it falls back to normal behavior"""
1100
+ mock_response = {"features": {"test_feature": {"defaultValue": "normal"}}, "savedGroups": {}}
1101
+
1102
+ call_count = 0
1103
+ def mock_fetch_features(api_host, client_key, decryption_key=""):
1104
+ nonlocal call_count
1105
+ call_count += 1
1106
+ return mock_response
1107
+
1108
+ # Clear cache and mock the fetch method
1109
+ feature_repo.clear_cache()
1110
+ m = mocker.patch.object(feature_repo, '_fetch_features', side_effect=mock_fetch_features)
1111
+
1112
+ # Create GrowthBook instance with stale-while-revalidate disabled (default)
1113
+ gb = GrowthBook(
1114
+ api_host="https://cdn.growthbook.io",
1115
+ client_key="test-key",
1116
+ cache_ttl=1, # Short TTL
1117
+ stale_while_revalidate=False # Explicitly disabled
1118
+ )
1119
+
1120
+ try:
1121
+ # Should NOT have started background refresh task
1122
+ assert feature_repo._refresh_thread is None
1123
+
1124
+ # Initial evaluation
1125
+ assert gb.get_feature_value('test_feature', 'default') == "normal"
1126
+ assert call_count == 1
1127
+
1128
+ # Manually expire the cache
1129
+ cache_key = "https://cdn.growthbook.io::test-key"
1130
+ if hasattr(feature_repo.cache, 'cache') and cache_key in feature_repo.cache.cache:
1131
+ feature_repo.cache.cache[cache_key].expires = time() - 10
1132
+
1133
+ # Next evaluation should fetch synchronously (normal behavior)
1134
+ assert gb.get_feature_value('test_feature', 'default') == "normal"
1135
+ assert call_count == 2 # Should have fetched again
1136
+
1137
+ finally:
1138
+ gb.destroy()
1139
+ feature_repo.clear_cache()
1140
+
1141
+
1142
+ def test_stale_while_revalidate_cleanup(mocker):
1143
+ """Test that background refresh is properly cleaned up"""
1144
+ mock_response = {"features": {"test_feature": {"defaultValue": "test"}}, "savedGroups": {}}
1145
+
1146
+ # Mock the fetch method
1147
+ feature_repo.clear_cache()
1148
+ m = mocker.patch.object(feature_repo, '_fetch_features', return_value=mock_response)
1149
+
1150
+ # Create GrowthBook instance with stale-while-revalidate enabled
1151
+ gb = GrowthBook(
1152
+ api_host="https://cdn.growthbook.io",
1153
+ client_key="test-key",
1154
+ stale_while_revalidate=True
1155
+ )
1156
+
1157
+ try:
1158
+ # Should have started background refresh task
1159
+ assert feature_repo._refresh_thread is not None
1160
+ assert feature_repo._refresh_thread.is_alive()
1161
+
1162
+ # Destroy should clean up the background task
1163
+ gb.destroy()
1164
+
1165
+ # Background task should be stopped
1166
+ assert feature_repo._refresh_thread is None or not feature_repo._refresh_thread.is_alive()
1167
+
1168
+ finally:
1169
+ # Ensure cleanup even if test fails
1170
+ if feature_repo._refresh_thread:
1171
+ feature_repo.stop_background_refresh()
1012
1172
  feature_repo.clear_cache()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes