Skip to content

Commit 06844f1

Browse files
Options to enforce fallback if config is out of sync (#344)
Context: 1. Forward proxy can be out of sync, keep sending back has no update value 2. We want to provide a option for them to enforce fallback if sdk is out of sync for x hours Default this option is off Test see added test
1 parent ec0d0d4 commit 06844f1

File tree

4 files changed

+93
-5
lines changed

4 files changed

+93
-5
lines changed

statsig/spec_updater.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import threading
3+
import time
34
from typing import Optional, Callable, List, Any, Tuple
45

56
from . import globals
@@ -44,6 +45,10 @@ def __init__(
4445
self._background_download_id_lists = None
4546
self._config_sync_strategies = self._get_sync_dcs_strategies()
4647
self._dcs_process_lock = threading.Lock()
48+
if options.out_of_sync_threshold_in_s is not None:
49+
self._enforce_sync_fallback_threshold_in_ms: Optional[float] = options.out_of_sync_threshold_in_s * 1000
50+
else:
51+
self._enforce_sync_fallback_threshold_in_ms = None
4752

4853
self.initialized = False
4954
self.last_update_time = 0
@@ -369,8 +374,11 @@ def sync_config_spec():
369374
for i, strategy in enumerate(self._config_sync_strategies):
370375
prev_failure_count = self._sync_failure_count
371376
self.get_config_spec(strategy)
372-
373-
if prev_failure_count == self._sync_failure_count:
377+
outof_sync = False
378+
time_elapsed = time.time() * 1000 - self.last_update_time
379+
if (self._enforce_sync_fallback_threshold_in_ms is not None and time_elapsed > self._enforce_sync_fallback_threshold_in_ms):
380+
outof_sync = True
381+
if prev_failure_count == self._sync_failure_count and not outof_sync:
374382
globals.logger.log_process("Config Sync", f"Syncing config values with {strategy.value} successful")
375383
break
376384
if i < len(self._config_sync_strategies) - 1:

statsig/statsig_options.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def __init__(
108108
retry_queue_size: int = DEFAULT_RETRY_QUEUE_SIZE,
109109
proxy_configs: Optional[Dict[NetworkEndpoint, ProxyConfig]] = None,
110110
fallback_to_statsig_api: Optional[bool] = False,
111+
out_of_sync_threshold_in_s: Optional[float] = None , # If config is out of sync for {threshold} s, we enforce to fallback logic you defined
111112
initialize_sources: Optional[List[DataSource]] = None,
112113
config_sync_sources: Optional[List[DataSource]] = None,
113114
output_logger_level: Optional[LogLevel] = LogLevel.WARNING,
@@ -148,6 +149,7 @@ def __init__(
148149
self.evaluation_callback = evaluation_callback
149150
self.retry_queue_size = retry_queue_size
150151
self.fallback_to_statsig_api = fallback_to_statsig_api
152+
self.out_of_sync_threshold_in_s = out_of_sync_threshold_in_s
151153
if proxy_configs is None:
152154
self.proxy_configs = DEFAULT_PROXY_CONFIG
153155
else:

tests/network_stub.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
from typing import Callable, Union
33
from urllib.parse import urlparse, ParseResult
44

5+
STATSIG_APIS = ["https://api.statsigcdn.com/", "https://statsigapi.net/"]
56

67
class NetworkStub:
78
host: str
9+
mock_statsig_api: bool
810

911
class StubResponse:
1012
def __init__(self, status, data=None, headers=None):
@@ -20,8 +22,9 @@ def __init__(self, status, data=None, headers=None):
2022
def json(self):
2123
return self._json
2224

23-
def __init__(self, host: str):
25+
def __init__(self, host: str, mock_statsig_api = False):
2426
self.host = host
27+
self.mock_statsig_api = mock_statsig_api
2528
self._stubs = {}
2629

2730
def reset(self):
@@ -52,8 +55,8 @@ def mock(*args, **kwargs):
5255
instance: NetworkStub = args[0]
5356
method: str = args[1]
5457
url: ParseResult = urlparse(args[2])
55-
56-
if (url.scheme + "://" + url.hostname) != instance.host:
58+
request_host = (url.scheme + "://" + url.hostname)
59+
if request_host != instance.host and (instance.mock_statsig_api and request_host not in STATSIG_APIS):
5760
return
5861

5962
paths = list(instance._stubs.keys())

tests/test_sync_config_fallback.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import json
2+
import os
3+
import time
4+
import unittest
5+
6+
from unittest.mock import patch
7+
from network_stub import NetworkStub
8+
from statsig import StatsigOptions, statsig, StatsigUser
9+
from statsig.evaluation_details import EvaluationDetails, EvaluationReason
10+
from statsig.http_worker import HttpWorker
11+
12+
_network_stub = NetworkStub("http://test-sync-config-fallback", mock_statsig_api=True)
13+
with open(os.path.join(os.path.abspath(os.path.dirname(__file__)), '../testdata/download_config_specs.json')) as r:
14+
CONFIG_SPECS_RESPONSE = r.read()
15+
16+
class TestSyncConfigFallback(unittest.TestCase):
17+
@classmethod
18+
@patch('requests.request', side_effect=_network_stub.mock)
19+
def setUpClass(cls, mock_proxy):
20+
cls.dcs_hit = 0
21+
_network_stub.reset()
22+
def dcs_proxy_callback(url: str, **kwargs):
23+
cls.dcs_hit += 1
24+
return json.loads(CONFIG_SPECS_RESPONSE)
25+
26+
_network_stub.stub_request_with_function(
27+
"download_config_specs/.*", 200, dcs_proxy_callback)
28+
29+
cls.test_user = StatsigUser("123", email="[email protected]")
30+
31+
def tearDown(self):
32+
self.dcs_hit = 0
33+
statsig.shutdown()
34+
35+
@patch('requests.request', side_effect=_network_stub.mock)
36+
@patch.object(HttpWorker, 'get_dcs_fallback')
37+
def test_default_behavior(self, fallback_mock, request_mock):
38+
# default behavior is no fallback if is out of sync
39+
options = StatsigOptions(api=_network_stub.host, fallback_to_statsig_api=True, rulesets_sync_interval=1)
40+
statsig.initialize("secret-key", options)
41+
gate = statsig.get_feature_gate(self.test_user, "always_on_gate")
42+
eval_detail: EvaluationDetails = gate.get_evaluation_details()
43+
self.assertEqual(eval_detail.reason, EvaluationReason.network)
44+
self.assertEqual(eval_detail.config_sync_time, 1631638014811)
45+
time.sleep(1.1)
46+
47+
fallback_mock.assert_not_called()
48+
49+
@patch('requests.request', side_effect=_network_stub.mock)
50+
@patch.object(HttpWorker, 'get_dcs_fallback')
51+
def test_fallback_when_out_of_sync(self, fallback_mock, request_mock):
52+
# default behavior is no fallback if is out of sync
53+
options = StatsigOptions(api_for_download_config_specs=_network_stub.host, fallback_to_statsig_api=True, rulesets_sync_interval=1, out_of_sync_threshold_in_s=0.5)
54+
statsig.initialize("secret-key", options)
55+
gate = statsig.get_feature_gate(self.test_user, "always_on_gate")
56+
eval_detail: EvaluationDetails = gate.get_evaluation_details()
57+
self.assertEqual(eval_detail.reason, EvaluationReason.network)
58+
self.assertEqual(eval_detail.config_sync_time, 1631638014811)
59+
time.sleep(1.1)
60+
#ensure it falls back
61+
fallback_mock.assert_called_once()
62+
63+
@patch('requests.request', side_effect=_network_stub.mock)
64+
@patch.object(HttpWorker, 'get_dcs_fallback')
65+
def test_behavior_when_not_out_of_sync(self, fallback_mock, request_mock):
66+
# default behavior is no fallback if is out of sync
67+
options = StatsigOptions(api_for_download_config_specs=_network_stub.host, fallback_to_statsig_api=True, rulesets_sync_interval=1, out_of_sync_threshold_in_s=4e10)
68+
statsig.initialize("secret-key", options)
69+
gate = statsig.get_feature_gate(self.test_user, "always_on_gate")
70+
eval_detail: EvaluationDetails = gate.get_evaluation_details()
71+
self.assertEqual(eval_detail.reason, EvaluationReason.network)
72+
self.assertEqual(eval_detail.config_sync_time, 1631638014811)
73+
time.sleep(1.1)
74+
#ensure no fallback
75+
fallback_mock.assert_not_called()

0 commit comments

Comments
 (0)