devcycle-python-server-sdk 3.13.5__py3-none-any.whl → 3.13.7__py3-none-any.whl
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.
- devcycle_python_sdk/VERSION.txt +1 -1
- devcycle_python_sdk/managers/config_manager.py +98 -8
- devcycle_python_sdk/managers/sse_manager.py +8 -3
- {devcycle_python_server_sdk-3.13.5.dist-info → devcycle_python_server_sdk-3.13.7.dist-info}/METADATA +1 -1
- {devcycle_python_server_sdk-3.13.5.dist-info → devcycle_python_server_sdk-3.13.7.dist-info}/RECORD +10 -10
- {devcycle_python_server_sdk-3.13.5.dist-info → devcycle_python_server_sdk-3.13.7.dist-info}/WHEEL +1 -1
- example/cloud_client_example.py +1 -1
- test/managers/test_config_manager.py +240 -0
- {devcycle_python_server_sdk-3.13.5.dist-info → devcycle_python_server_sdk-3.13.7.dist-info}/licenses/LICENSE +0 -0
- {devcycle_python_server_sdk-3.13.5.dist-info → devcycle_python_server_sdk-3.13.7.dist-info}/top_level.txt +0 -0
devcycle_python_sdk/VERSION.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.13.
|
|
1
|
+
3.13.7
|
|
@@ -40,6 +40,12 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
40
40
|
self._config_etag: Optional[str] = None
|
|
41
41
|
self._config_lastmodified: Optional[str] = None
|
|
42
42
|
|
|
43
|
+
# Exponential backoff configuration
|
|
44
|
+
self._sse_reconnect_attempts = 0
|
|
45
|
+
self._min_reconnect_interval = 5.0 # Start at 5 seconds
|
|
46
|
+
self._max_reconnect_interval = 300.0 # Cap at 5 minutes
|
|
47
|
+
self._last_reconnect_attempt_time: Optional[float] = None
|
|
48
|
+
self._sse_reconnecting = False
|
|
43
49
|
self._config_api_client = ConfigAPIClient(self._sdk_key, self._options)
|
|
44
50
|
|
|
45
51
|
self._polling_enabled = True
|
|
@@ -49,6 +55,48 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
49
55
|
def is_initialized(self) -> bool:
|
|
50
56
|
return self._config is not None
|
|
51
57
|
|
|
58
|
+
def _recreate_sse_connection(self):
|
|
59
|
+
"""Recreate the SSE connection with the current config."""
|
|
60
|
+
if self._config is None or self._options.disable_realtime_updates:
|
|
61
|
+
logger.debug(
|
|
62
|
+
"DevCycle: Skipping SSE recreation - no config or updates disabled"
|
|
63
|
+
)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Update timestamp right before attempting connection
|
|
67
|
+
self._last_reconnect_attempt_time = time.time()
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
# Close existing connection if present
|
|
71
|
+
if self._sse_manager is not None and self._sse_manager.client is not None:
|
|
72
|
+
self._sse_manager.client.close()
|
|
73
|
+
if self._sse_manager.read_thread.is_alive():
|
|
74
|
+
self._sse_manager.read_thread.join(timeout=1.0)
|
|
75
|
+
|
|
76
|
+
# Create new SSE manager
|
|
77
|
+
self._sse_manager = SSEManager(
|
|
78
|
+
self.sse_state,
|
|
79
|
+
self.sse_error,
|
|
80
|
+
self.sse_message,
|
|
81
|
+
)
|
|
82
|
+
self._sse_manager.update(self._config)
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.debug(f"DevCycle: Failed to recreate SSE connection: {e}")
|
|
86
|
+
|
|
87
|
+
def _delayed_sse_reconnect(self, delay_seconds: float):
|
|
88
|
+
"""Delayed SSE reconnection with configurable backoff."""
|
|
89
|
+
try:
|
|
90
|
+
logger.debug(
|
|
91
|
+
f"DevCycle: Waiting {delay_seconds}s before reconnecting SSE..."
|
|
92
|
+
)
|
|
93
|
+
time.sleep(delay_seconds)
|
|
94
|
+
self._recreate_sse_connection()
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.error(f"DevCycle: Error during delayed SSE reconnection: {e}")
|
|
97
|
+
finally:
|
|
98
|
+
self._sse_reconnecting = False
|
|
99
|
+
|
|
52
100
|
def _get_config(self, last_modified: Optional[float] = None):
|
|
53
101
|
try:
|
|
54
102
|
lm_header = self._config_lastmodified
|
|
@@ -87,12 +135,10 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
87
135
|
or self._sse_manager.client is None
|
|
88
136
|
or not self._sse_manager.read_thread.is_alive()
|
|
89
137
|
):
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
self.sse_error,
|
|
93
|
-
self.sse_message,
|
|
138
|
+
logger.info(
|
|
139
|
+
"DevCycle: SSE connection not active, creating new connection"
|
|
94
140
|
)
|
|
95
|
-
|
|
141
|
+
self._recreate_sse_connection()
|
|
96
142
|
|
|
97
143
|
if (
|
|
98
144
|
trigger_on_client_initialized
|
|
@@ -101,7 +147,6 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
101
147
|
try:
|
|
102
148
|
self._options.on_client_initialized()
|
|
103
149
|
except Exception as e:
|
|
104
|
-
# consume any error
|
|
105
150
|
logger.warning(
|
|
106
151
|
f"DevCycle: Error received from on_client_initialized callback: {str(e)}"
|
|
107
152
|
)
|
|
@@ -122,7 +167,6 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
122
167
|
self._get_config()
|
|
123
168
|
except Exception as e:
|
|
124
169
|
if self._polling_enabled:
|
|
125
|
-
# Only log a warning if we're still polling
|
|
126
170
|
logger.warning(
|
|
127
171
|
f"DevCycle: Error polling for config changes: {str(e)}"
|
|
128
172
|
)
|
|
@@ -137,6 +181,7 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
137
181
|
self.sse_state(None)
|
|
138
182
|
logger.info(f"DevCycle: Received message: {message.data}")
|
|
139
183
|
sse_message = json.loads(message.data)
|
|
184
|
+
|
|
140
185
|
dvc_data = json.loads(sse_message.get("data"))
|
|
141
186
|
if (
|
|
142
187
|
dvc_data.get("type") == "refetchConfig"
|
|
@@ -145,15 +190,60 @@ class EnvironmentConfigManager(threading.Thread):
|
|
|
145
190
|
):
|
|
146
191
|
logger.info("DevCycle: Received refetchConfig message - updating config")
|
|
147
192
|
self._get_config(dvc_data["lastModified"] / 1000.0)
|
|
193
|
+
# SSE connection healthy, reconnect attempts reset.
|
|
194
|
+
if dvc_data.get("type") == "ping" or dvc_data.get("type") == "refetchConfig":
|
|
195
|
+
self._sse_reconnect_attempts = 0
|
|
148
196
|
|
|
149
197
|
def sse_error(self, error: ld_eventsource.actions.Fault):
|
|
150
198
|
self._sse_connected = False
|
|
151
|
-
logger.debug(f"DevCycle:
|
|
199
|
+
logger.debug(f"DevCycle: SSE connection error: {error.error}")
|
|
200
|
+
current_time = time.time()
|
|
201
|
+
|
|
202
|
+
if self._sse_reconnecting:
|
|
203
|
+
logger.debug("DevCycle: Reconnection already in progress, skipping")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
# Calculate exponential backoff interval (capped at max)
|
|
207
|
+
backoff_interval = min(
|
|
208
|
+
self._min_reconnect_interval * (2**self._sse_reconnect_attempts),
|
|
209
|
+
self._max_reconnect_interval,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Check if we need to wait for remaining backoff time
|
|
213
|
+
delay_seconds = backoff_interval
|
|
214
|
+
if self._last_reconnect_attempt_time is not None:
|
|
215
|
+
time_since_last_attempt = current_time - self._last_reconnect_attempt_time
|
|
216
|
+
if time_since_last_attempt < backoff_interval:
|
|
217
|
+
delay_seconds = backoff_interval - time_since_last_attempt
|
|
218
|
+
logger.debug(
|
|
219
|
+
f"DevCycle: Within backoff period, scheduling reconnection in {delay_seconds:.1f}s"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
self._sse_reconnecting = True
|
|
223
|
+
self._sse_reconnect_attempts += 1
|
|
224
|
+
|
|
225
|
+
logger.debug(
|
|
226
|
+
f"DevCycle: Attempting SSE reconnection (attempt #{self._sse_reconnect_attempts}, "
|
|
227
|
+
f"backoff: {delay_seconds:.1f}s)"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
reconnect_thread = threading.Thread(
|
|
231
|
+
target=self._delayed_sse_reconnect, args=(delay_seconds,), daemon=True
|
|
232
|
+
)
|
|
233
|
+
reconnect_thread.start()
|
|
152
234
|
|
|
153
235
|
def sse_state(self, state: Optional[ld_eventsource.actions.Start]):
|
|
154
236
|
if not self._sse_connected:
|
|
155
237
|
self._sse_connected = True
|
|
156
238
|
logger.info("DevCycle: Connected to SSE stream")
|
|
157
239
|
|
|
240
|
+
# Clear reconnection state
|
|
241
|
+
self._sse_reconnecting = False
|
|
242
|
+
self._last_reconnect_attempt_time = None
|
|
243
|
+
else:
|
|
244
|
+
logger.debug("DevCycle: SSE keepalive received")
|
|
245
|
+
|
|
158
246
|
def close(self):
|
|
159
247
|
self._polling_enabled = False
|
|
248
|
+
if self._sse_manager is not None and self._sse_manager.client is not None:
|
|
249
|
+
self._sse_manager.client.close()
|
|
@@ -33,8 +33,9 @@ class SSEManager:
|
|
|
33
33
|
handle_error: Callable[[ld_eventsource.actions.Fault], None],
|
|
34
34
|
handle_message: Callable[[ld_eventsource.actions.Event], None],
|
|
35
35
|
):
|
|
36
|
-
self.client.start()
|
|
37
36
|
try:
|
|
37
|
+
self.client.start()
|
|
38
|
+
logger.info("DevCycle: SSE connection created successfully")
|
|
38
39
|
for event in self.client.all:
|
|
39
40
|
if isinstance(event, ld_eventsource.actions.Start):
|
|
40
41
|
handle_state(event)
|
|
@@ -45,7 +46,11 @@ class SSEManager:
|
|
|
45
46
|
elif isinstance(event, ld_eventsource.actions.Comment):
|
|
46
47
|
handle_state(None)
|
|
47
48
|
except Exception as e:
|
|
48
|
-
logger.debug(f"DevCycle:
|
|
49
|
+
logger.debug(f"DevCycle SSE: Error in read loop: {e}")
|
|
50
|
+
fault_event = ld_eventsource.actions.Fault(error=e)
|
|
51
|
+
handle_error(fault_event)
|
|
52
|
+
finally:
|
|
53
|
+
logger.debug("DevCycle SSE: Connection closed")
|
|
49
54
|
|
|
50
55
|
def update(self, config: dict):
|
|
51
56
|
if self.use_new_config(config["sse"]):
|
|
@@ -66,6 +71,6 @@ class SSEManager:
|
|
|
66
71
|
|
|
67
72
|
def use_new_config(self, config: dict) -> bool:
|
|
68
73
|
new_url = config["hostname"] + config["path"]
|
|
69
|
-
if self.url == "" or self.url is None and new_url != "":
|
|
74
|
+
if (self.url == "" or self.url is None) and new_url != "":
|
|
70
75
|
return True
|
|
71
76
|
return self.url != new_url
|
{devcycle_python_server_sdk-3.13.5.dist-info → devcycle_python_server_sdk-3.13.7.dist-info}/RECORD
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
devcycle_python_sdk/VERSION.txt,sha256=
|
|
1
|
+
devcycle_python_sdk/VERSION.txt,sha256=POFulFkFQ6Mn0-WuQS5mMgbg7RtGYo7T3VrSnKof9aw,7
|
|
2
2
|
devcycle_python_sdk/__init__.py,sha256=30TcYb9PbvVi97gwk16omCGG1UoUI9lmmU7xpvxNMvc,336
|
|
3
3
|
devcycle_python_sdk/bucketing-lib.release.wasm,sha256=6iooyHRR-GBfrBH0_mleWyaNHr9uM_nVPS-IXGROGCY,224670
|
|
4
4
|
devcycle_python_sdk/cloud_client.py,sha256=dR2ye29Up699hO1cdrAWeoXIOBPVoSvQ4aKe2gSBRno,9414
|
|
@@ -14,10 +14,10 @@ devcycle_python_sdk/api/config_client.py,sha256=73aQAq130rTHpQrVDX6n_DwFKz0WuKxR
|
|
|
14
14
|
devcycle_python_sdk/api/event_client.py,sha256=WxkuV6geEC9MgG4FN36HeEQQa22ilq2mkOVMZkpiEk4,3356
|
|
15
15
|
devcycle_python_sdk/api/local_bucketing.py,sha256=kXggXhv2PDQxDoLseuJwWbge8EgeqL0FWTmEIn8TUQc,16704
|
|
16
16
|
devcycle_python_sdk/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
-
devcycle_python_sdk/managers/config_manager.py,sha256=
|
|
17
|
+
devcycle_python_sdk/managers/config_manager.py,sha256=tt9D_V3kLwL1O3a03a6QUln5AbEedSCKD1twqGJJpPU,9819
|
|
18
18
|
devcycle_python_sdk/managers/eval_hooks_manager.py,sha256=YOLj0IvNFqDbWvPA2aOqT15yKkwsdnZT9MQ7MHTv6M4,2963
|
|
19
19
|
devcycle_python_sdk/managers/event_queue_manager.py,sha256=8tqPZDD0-AWmz_NcWJBb80OIMzNBpDgbafpFefiOOmk,8109
|
|
20
|
-
devcycle_python_sdk/managers/sse_manager.py,sha256=
|
|
20
|
+
devcycle_python_sdk/managers/sse_manager.py,sha256=tlIfcavZxDVmboAGyt-5li99TrwBOLJ6EcwEriAx7I8,2951
|
|
21
21
|
devcycle_python_sdk/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
22
|
devcycle_python_sdk/models/bucketed_config.py,sha256=qTfhIViX7m2WS2oDBBCmKKzPsJG6DEav3zCEXwP27nA,3980
|
|
23
23
|
devcycle_python_sdk/models/config_metadata.py,sha256=HHhw2ncHOUAC3SH-IokVP4GrNLbflcGE9p7a2ZSuksA,1413
|
|
@@ -42,9 +42,9 @@ devcycle_python_sdk/protobuf/variableForUserParams_pb2.pyi,sha256=OU4ORhjnbXy0Aq
|
|
|
42
42
|
devcycle_python_sdk/util/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
43
43
|
devcycle_python_sdk/util/strings.py,sha256=gH0Xm3gIXNqn6hczKPk10Jn5_9knuD7OB5MrB7bqztw,252
|
|
44
44
|
devcycle_python_sdk/util/version.py,sha256=Cz7Us_EuXP-T8GI47g2WHYdBJj_zKo-5y545IDHJAjI,253
|
|
45
|
-
devcycle_python_server_sdk-3.13.
|
|
45
|
+
devcycle_python_server_sdk-3.13.7.dist-info/licenses/LICENSE,sha256=RlvZsEX5o__wLAA-VE7mG5hzTPjnFzPJKNdXXKvZ854,1071
|
|
46
46
|
example/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
47
|
-
example/cloud_client_example.py,sha256=
|
|
47
|
+
example/cloud_client_example.py,sha256=tkWVxMCfuenUoKnFIGou7axfp9-8FZRDk5HuVYjiXuU,2463
|
|
48
48
|
example/local_bucketing_client_example.py,sha256=TaIfy3VnW9fXii9NPYs5UOuP4fLKruXMjnZApveqzl4,2796
|
|
49
49
|
example/openfeature_example.py,sha256=8rdEtG_RiEbCwbQQWcCXtfZURz2Z393WoczyperkNug,8105
|
|
50
50
|
test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -58,7 +58,7 @@ test/api/test_local_bucketing.py,sha256=ECouaWpgWrlfEBtTK5OavmYX87XiXKg-9epmzTOC
|
|
|
58
58
|
test/fixture/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
59
59
|
test/fixture/data.py,sha256=6ReLxWPMhCDbJ5Ex24_iR0PJNjKzPFV1I9j_v0FVbBU,1462
|
|
60
60
|
test/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
61
|
-
test/managers/test_config_manager.py,sha256=
|
|
61
|
+
test/managers/test_config_manager.py,sha256=8X3gECiD5V26BmNev5qiZut7r6i-gs69VkXw1SbxEXQ,15604
|
|
62
62
|
test/managers/test_event_queue_manager.py,sha256=z850atk8zAlD7sYaA1OJwgCFcfC0ps4NEk4XUnNCq1U,11629
|
|
63
63
|
test/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
64
64
|
test/models/test_bucketed_config.py,sha256=8wCEyxaHxY21nl7ZbiFzp-IzZQBZIPIVPPzeREjumQk,858
|
|
@@ -70,7 +70,7 @@ test/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
70
70
|
test/util/test_strings.py,sha256=T5H5U0af7_ld1yJC8IdDmay_Mx-iJQ5Fi5fbvKcAc7A,862
|
|
71
71
|
test/util/test_utils.py,sha256=tVBJm_qnqg-4PssLpXIxqlk0JehuiAIPs4zpi2oNVnY,6671
|
|
72
72
|
test/util/test_version.py,sha256=3Xg0_84m0bdHXz6pxd2Z85Oz9DoUC1T0Ol_WqEH_jac,335
|
|
73
|
-
devcycle_python_server_sdk-3.13.
|
|
74
|
-
devcycle_python_server_sdk-3.13.
|
|
75
|
-
devcycle_python_server_sdk-3.13.
|
|
76
|
-
devcycle_python_server_sdk-3.13.
|
|
73
|
+
devcycle_python_server_sdk-3.13.7.dist-info/METADATA,sha256=e9fshN_k_M30OqcGScL77jLWFF_HHd30-o-YfzsuUts,723
|
|
74
|
+
devcycle_python_server_sdk-3.13.7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
75
|
+
devcycle_python_server_sdk-3.13.7.dist-info/top_level.txt,sha256=34f8zDzihAHoH7siAFlmscyO_QLNWWq9HApFlwtYL4k,33
|
|
76
|
+
devcycle_python_server_sdk-3.13.7.dist-info/RECORD,,
|
example/cloud_client_example.py
CHANGED
|
@@ -8,6 +8,8 @@ from email.utils import formatdate
|
|
|
8
8
|
from time import mktime
|
|
9
9
|
from unittest.mock import patch, MagicMock
|
|
10
10
|
|
|
11
|
+
import ld_eventsource.actions
|
|
12
|
+
|
|
11
13
|
from devcycle_python_sdk import DevCycleLocalOptions
|
|
12
14
|
from devcycle_python_sdk.managers.config_manager import EnvironmentConfigManager
|
|
13
15
|
from test.fixture.data import small_config_json
|
|
@@ -152,5 +154,243 @@ class EnvironmentConfigManagerTest(unittest.TestCase):
|
|
|
152
154
|
self.test_local_bucketing.store_config.assert_not_called()
|
|
153
155
|
|
|
154
156
|
|
|
157
|
+
class SSEReconnectionBackoffTest(unittest.TestCase):
|
|
158
|
+
"""Tests for SSE exponential backoff reconnection behavior"""
|
|
159
|
+
|
|
160
|
+
def setUp(self) -> None:
|
|
161
|
+
self.sdk_key = "dvc_server_" + str(uuid.uuid4())
|
|
162
|
+
self.test_local_bucketing = MagicMock()
|
|
163
|
+
self.test_options = DevCycleLocalOptions(
|
|
164
|
+
config_polling_interval_ms=500, disable_realtime_updates=False
|
|
165
|
+
)
|
|
166
|
+
self.test_config_json = small_config_json()
|
|
167
|
+
|
|
168
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
169
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
170
|
+
@patch("threading.Thread")
|
|
171
|
+
@patch("time.time")
|
|
172
|
+
def test_first_sse_error_triggers_immediate_reconnection(
|
|
173
|
+
self, mock_time, mock_thread, mock_sse_manager, mock_get_config
|
|
174
|
+
):
|
|
175
|
+
"""First error should trigger reconnection with min backoff (5s)"""
|
|
176
|
+
mock_time.return_value = 1000.0
|
|
177
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
178
|
+
|
|
179
|
+
config_manager = EnvironmentConfigManager(
|
|
180
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
181
|
+
)
|
|
182
|
+
time.sleep(0.1)
|
|
183
|
+
|
|
184
|
+
# Simulate first SSE error
|
|
185
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
186
|
+
config_manager.sse_error(error)
|
|
187
|
+
|
|
188
|
+
# Verify reconnection attempt counter incremented
|
|
189
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 1)
|
|
190
|
+
# Verify reconnecting flag set
|
|
191
|
+
self.assertTrue(config_manager._sse_reconnecting)
|
|
192
|
+
# Verify thread spawned with min backoff (5.0s)
|
|
193
|
+
mock_thread.assert_called_once()
|
|
194
|
+
call_args = mock_thread.call_args
|
|
195
|
+
self.assertEqual(call_args[1]["args"][0], 5.0) # backoff_interval
|
|
196
|
+
|
|
197
|
+
config_manager.close()
|
|
198
|
+
|
|
199
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
200
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
201
|
+
@patch("threading.Thread")
|
|
202
|
+
@patch("time.time")
|
|
203
|
+
def test_exponential_backoff_calculation(
|
|
204
|
+
self, mock_time, mock_thread, mock_sse_manager, mock_get_config
|
|
205
|
+
):
|
|
206
|
+
"""Verify exponential backoff: 5s, 10s, 20s, 40s, etc."""
|
|
207
|
+
mock_time.return_value = 1000.0
|
|
208
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
209
|
+
|
|
210
|
+
config_manager = EnvironmentConfigManager(
|
|
211
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
212
|
+
)
|
|
213
|
+
time.sleep(0.1)
|
|
214
|
+
|
|
215
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
216
|
+
|
|
217
|
+
# First error: 5s backoff (2^0 * 5)
|
|
218
|
+
config_manager.sse_error(error)
|
|
219
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 1)
|
|
220
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 5.0)
|
|
221
|
+
|
|
222
|
+
# Simulate reconnect completing
|
|
223
|
+
config_manager._sse_reconnecting = False
|
|
224
|
+
mock_time.return_value += 10.0 # Advance time beyond backoff
|
|
225
|
+
|
|
226
|
+
# Second error: 10s backoff (2^1 * 5)
|
|
227
|
+
config_manager.sse_error(error)
|
|
228
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 2)
|
|
229
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 10.0)
|
|
230
|
+
|
|
231
|
+
# Simulate reconnect completing
|
|
232
|
+
config_manager._sse_reconnecting = False
|
|
233
|
+
mock_time.return_value += 15.0
|
|
234
|
+
|
|
235
|
+
# Third error: 20s backoff (2^2 * 5)
|
|
236
|
+
config_manager.sse_error(error)
|
|
237
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 3)
|
|
238
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 20.0)
|
|
239
|
+
|
|
240
|
+
config_manager.close()
|
|
241
|
+
|
|
242
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
243
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
244
|
+
@patch("threading.Thread")
|
|
245
|
+
@patch("time.time")
|
|
246
|
+
def test_backoff_caps_at_max_interval(
|
|
247
|
+
self, mock_time, mock_thread, mock_sse_manager, mock_get_config
|
|
248
|
+
):
|
|
249
|
+
"""Verify backoff caps at max interval (300s)"""
|
|
250
|
+
mock_time.return_value = 1000.0
|
|
251
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
252
|
+
|
|
253
|
+
config_manager = EnvironmentConfigManager(
|
|
254
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
255
|
+
)
|
|
256
|
+
time.sleep(0.1)
|
|
257
|
+
|
|
258
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
259
|
+
|
|
260
|
+
# Simulate many failures to reach max backoff
|
|
261
|
+
# 2^6 * 5 = 320s > 300s (max), so should cap at 300s
|
|
262
|
+
config_manager._sse_reconnect_attempts = 6
|
|
263
|
+
config_manager.sse_error(error)
|
|
264
|
+
|
|
265
|
+
# Should be capped at 300s
|
|
266
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 300.0)
|
|
267
|
+
|
|
268
|
+
config_manager.close()
|
|
269
|
+
|
|
270
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
271
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
272
|
+
@patch("threading.Thread")
|
|
273
|
+
def test_concurrent_errors_only_spawn_one_reconnection(
|
|
274
|
+
self, mock_thread, mock_sse_manager, mock_get_config
|
|
275
|
+
):
|
|
276
|
+
"""Multiple rapid errors should only spawn one reconnection thread"""
|
|
277
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
278
|
+
|
|
279
|
+
config_manager = EnvironmentConfigManager(
|
|
280
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
281
|
+
)
|
|
282
|
+
time.sleep(0.1)
|
|
283
|
+
|
|
284
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
285
|
+
|
|
286
|
+
# First error spawns reconnection
|
|
287
|
+
config_manager.sse_error(error)
|
|
288
|
+
self.assertEqual(mock_thread.call_count, 1)
|
|
289
|
+
self.assertTrue(config_manager._sse_reconnecting)
|
|
290
|
+
|
|
291
|
+
# Second error while reconnecting should be skipped
|
|
292
|
+
config_manager.sse_error(error)
|
|
293
|
+
# Still only 1 thread spawned
|
|
294
|
+
self.assertEqual(mock_thread.call_count, 1)
|
|
295
|
+
|
|
296
|
+
config_manager.close()
|
|
297
|
+
|
|
298
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
299
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
300
|
+
@patch("threading.Thread")
|
|
301
|
+
@patch("time.time")
|
|
302
|
+
def test_error_within_backoff_uses_remaining_time(
|
|
303
|
+
self, mock_time, mock_thread, mock_sse_manager, mock_get_config
|
|
304
|
+
):
|
|
305
|
+
"""Error within backoff period should schedule reconnect with remaining time"""
|
|
306
|
+
mock_time.return_value = 1000.0
|
|
307
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
308
|
+
|
|
309
|
+
config_manager = EnvironmentConfigManager(
|
|
310
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
311
|
+
)
|
|
312
|
+
time.sleep(0.1)
|
|
313
|
+
|
|
314
|
+
error = ld_eventsource.actions.Fault(error=Exception("Connection failed"))
|
|
315
|
+
|
|
316
|
+
# First error at t=1000, backoff=5s
|
|
317
|
+
config_manager.sse_error(error)
|
|
318
|
+
self.assertEqual(mock_thread.call_args[1]["args"][0], 5.0)
|
|
319
|
+
|
|
320
|
+
# Simulate reconnect completing and updating timestamp
|
|
321
|
+
config_manager._sse_reconnecting = False
|
|
322
|
+
config_manager._last_reconnect_attempt_time = (
|
|
323
|
+
1005.0 # Simulates reconnect at t=1005
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Second error at t=1008 (3s after reconnect, within 10s backoff)
|
|
327
|
+
mock_time.return_value = 1008.0
|
|
328
|
+
config_manager.sse_error(error)
|
|
329
|
+
|
|
330
|
+
# Should use remaining time: 10s backoff - 3s elapsed = 7s
|
|
331
|
+
self.assertAlmostEqual(mock_thread.call_args[1]["args"][0], 7.0, places=1)
|
|
332
|
+
|
|
333
|
+
config_manager.close()
|
|
334
|
+
|
|
335
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
336
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
337
|
+
def test_successful_connection_resets_attempts(
|
|
338
|
+
self, mock_sse_manager, mock_get_config
|
|
339
|
+
):
|
|
340
|
+
"""Successful SSE connection should reset reconnection attempts"""
|
|
341
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
342
|
+
|
|
343
|
+
config_manager = EnvironmentConfigManager(
|
|
344
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
345
|
+
)
|
|
346
|
+
time.sleep(0.1)
|
|
347
|
+
|
|
348
|
+
# Simulate multiple failures
|
|
349
|
+
config_manager._sse_reconnect_attempts = 5
|
|
350
|
+
config_manager._sse_reconnecting = True
|
|
351
|
+
config_manager._last_reconnect_attempt_time = 1000.0
|
|
352
|
+
|
|
353
|
+
# Simulate successful ping message (which resets attempts)
|
|
354
|
+
message = ld_eventsource.actions.Event(
|
|
355
|
+
event="message",
|
|
356
|
+
data='{"data": "{\\"type\\": \\"ping\\", \\"lastModified\\": 1234567890000}"}',
|
|
357
|
+
)
|
|
358
|
+
config_manager.sse_message(message)
|
|
359
|
+
|
|
360
|
+
# Attempts should be reset
|
|
361
|
+
self.assertEqual(config_manager._sse_reconnect_attempts, 0)
|
|
362
|
+
|
|
363
|
+
config_manager.close()
|
|
364
|
+
|
|
365
|
+
@patch("devcycle_python_sdk.api.config_client.ConfigAPIClient.get_config")
|
|
366
|
+
@patch("devcycle_python_sdk.managers.config_manager.SSEManager")
|
|
367
|
+
def test_successful_state_resets_reconnection_flags(
|
|
368
|
+
self, mock_sse_manager, mock_get_config
|
|
369
|
+
):
|
|
370
|
+
"""Successful SSE state should clear reconnection flags"""
|
|
371
|
+
mock_get_config.return_value = (self.test_config_json, "etag", None)
|
|
372
|
+
|
|
373
|
+
config_manager = EnvironmentConfigManager(
|
|
374
|
+
self.sdk_key, self.test_options, self.test_local_bucketing
|
|
375
|
+
)
|
|
376
|
+
time.sleep(0.1)
|
|
377
|
+
|
|
378
|
+
# Simulate reconnection in progress
|
|
379
|
+
config_manager._sse_reconnecting = True
|
|
380
|
+
config_manager._last_reconnect_attempt_time = 1000.0
|
|
381
|
+
config_manager._sse_connected = False
|
|
382
|
+
|
|
383
|
+
# Simulate successful connection
|
|
384
|
+
state = ld_eventsource.actions.Start()
|
|
385
|
+
config_manager.sse_state(state)
|
|
386
|
+
|
|
387
|
+
# Should clear reconnection state
|
|
388
|
+
self.assertFalse(config_manager._sse_reconnecting)
|
|
389
|
+
self.assertIsNone(config_manager._last_reconnect_attempt_time)
|
|
390
|
+
self.assertTrue(config_manager._sse_connected)
|
|
391
|
+
|
|
392
|
+
config_manager.close()
|
|
393
|
+
|
|
394
|
+
|
|
155
395
|
if __name__ == "__main__":
|
|
156
396
|
unittest.main()
|
|
File without changes
|
|
File without changes
|