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.
- {growthbook-1.4.2/growthbook.egg-info → growthbook-1.4.4}/PKG-INFO +1 -1
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/__init__.py +1 -1
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/growthbook.py +251 -76
- {growthbook-1.4.2 → growthbook-1.4.4/growthbook.egg-info}/PKG-INFO +1 -1
- {growthbook-1.4.2 → growthbook-1.4.4}/setup.cfg +1 -1
- {growthbook-1.4.2 → growthbook-1.4.4}/tests/test_growthbook.py +160 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/LICENSE +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/MANIFEST.in +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/README.md +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/common_types.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/core.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/growthbook_client.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/plugins/__init__.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/plugins/base.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/plugins/growthbook_tracking.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/plugins/request_context.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook/py.typed +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook.egg-info/SOURCES.txt +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook.egg-info/dependency_links.txt +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook.egg-info/requires.txt +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/growthbook.egg-info/top_level.txt +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/pyproject.toml +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/setup.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/tests/conftest.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/tests/test_growthbook_client.py +0 -0
- {growthbook-1.4.2 → growthbook-1.4.4}/tests/test_plugins.py +0 -0
|
@@ -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()
|
|
@@ -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
|
-
|
|
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.
|
|
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=
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
task.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
644
|
-
|
|
789
|
+
def destroy(self, timeout=10) -> None:
|
|
790
|
+
"""Gracefully destroy the GrowthBook instance"""
|
|
791
|
+
logger.debug("Starting GrowthBook destroy process")
|
|
645
792
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
|
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()
|
|
@@ -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
|
|
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
|