devcycle-python-server-sdk 3.13.4__py3-none-any.whl → 3.13.6__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.
@@ -1 +1 @@
1
- 3.13.4
1
+ 3.13.6
@@ -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
@@ -84,14 +132,13 @@ class EnvironmentConfigManager(threading.Thread):
84
132
  if not self._options.disable_realtime_updates:
85
133
  if (
86
134
  self._sse_manager is None
87
- or not self._sse_manager.client.is_connected()
135
+ or self._sse_manager.client is None
136
+ or not self._sse_manager.read_thread.is_alive()
88
137
  ):
89
- self._sse_manager = SSEManager(
90
- self.sse_state,
91
- self.sse_error,
92
- self.sse_message,
138
+ logger.info(
139
+ "DevCycle: SSE connection not active, creating new connection"
93
140
  )
94
- self._sse_manager.update(self._config)
141
+ self._recreate_sse_connection()
95
142
 
96
143
  if (
97
144
  trigger_on_client_initialized
@@ -100,7 +147,6 @@ class EnvironmentConfigManager(threading.Thread):
100
147
  try:
101
148
  self._options.on_client_initialized()
102
149
  except Exception as e:
103
- # consume any error
104
150
  logger.warning(
105
151
  f"DevCycle: Error received from on_client_initialized callback: {str(e)}"
106
152
  )
@@ -121,7 +167,6 @@ class EnvironmentConfigManager(threading.Thread):
121
167
  self._get_config()
122
168
  except Exception as e:
123
169
  if self._polling_enabled:
124
- # Only log a warning if we're still polling
125
170
  logger.warning(
126
171
  f"DevCycle: Error polling for config changes: {str(e)}"
127
172
  )
@@ -136,6 +181,7 @@ class EnvironmentConfigManager(threading.Thread):
136
181
  self.sse_state(None)
137
182
  logger.info(f"DevCycle: Received message: {message.data}")
138
183
  sse_message = json.loads(message.data)
184
+
139
185
  dvc_data = json.loads(sse_message.get("data"))
140
186
  if (
141
187
  dvc_data.get("type") == "refetchConfig"
@@ -144,15 +190,60 @@ class EnvironmentConfigManager(threading.Thread):
144
190
  ):
145
191
  logger.info("DevCycle: Received refetchConfig message - updating config")
146
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
147
196
 
148
197
  def sse_error(self, error: ld_eventsource.actions.Fault):
149
198
  self._sse_connected = False
150
- logger.debug(f"DevCycle: Received SSE error: {error}")
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()
151
234
 
152
235
  def sse_state(self, state: Optional[ld_eventsource.actions.Start]):
153
236
  if not self._sse_connected:
154
237
  self._sse_connected = True
155
238
  logger.info("DevCycle: Connected to SSE stream")
156
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
+
157
246
  def close(self):
158
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: failed to read SSE message: {e}")
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devcycle-python-server-sdk
3
- Version: 3.13.4
3
+ Version: 3.13.6
4
4
  Summary: DevCycle Python SDK
5
5
  Home-page: https://github.com/devcycleHQ/python-server-sdk
6
6
  Author-email:
@@ -1,4 +1,4 @@
1
- devcycle_python_sdk/VERSION.txt,sha256=OCUo4FBVyMS7eMRyPVJvqhtPmBd7OFFkwEW7sQpGQd8,7
1
+ devcycle_python_sdk/VERSION.txt,sha256=ME4_Xe9dQLO3Ens64jx-uAB7vLk_LR2cEJkILClIUXg,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=_2mHZvXCS5pcLW3h-nOkUkbz1MCMmpMfD1ZnhPZKxwU,6056
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=Yr8qNt_XXvi2E94JfRRZk5QSn5aX0GisJYpvZUik4hA,2697
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.4.dist-info/licenses/LICENSE,sha256=RlvZsEX5o__wLAA-VE7mG5hzTPjnFzPJKNdXXKvZ854,1071
45
+ devcycle_python_server_sdk-3.13.6.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=YD8ZsTv3dPYfbFUKraRpRT0VsJGrPwjm8ZCuA5H54yI,2463
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=2TUnt8501KM7UlJNH7srIhvC-izehD7nOfqZN5XgPJI,6016
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.4.dist-info/METADATA,sha256=NdIDJVbhBEioP9FGqWXdIiE4yZ2s2wik82AGnGfdO_I,723
74
- devcycle_python_server_sdk-3.13.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
75
- devcycle_python_server_sdk-3.13.4.dist-info/top_level.txt,sha256=34f8zDzihAHoH7siAFlmscyO_QLNWWq9HApFlwtYL4k,33
76
- devcycle_python_server_sdk-3.13.4.dist-info/RECORD,,
73
+ devcycle_python_server_sdk-3.13.6.dist-info/METADATA,sha256=J402_Y6WSqjviHu0IWPJ2RbcKbm6VyDChgDX2DGHHGo,723
74
+ devcycle_python_server_sdk-3.13.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
75
+ devcycle_python_server_sdk-3.13.6.dist-info/top_level.txt,sha256=34f8zDzihAHoH7siAFlmscyO_QLNWWq9HApFlwtYL4k,33
76
+ devcycle_python_server_sdk-3.13.6.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -61,7 +61,7 @@ def main():
61
61
  client.track(user, event)
62
62
 
63
63
  except Exception as e:
64
- logger.exception(f"Exception when calling Devcycle API: {e}\n")
64
+ logger.exception(f"Exception when calling DevCycle API: {e}\n")
65
65
 
66
66
 
67
67
  if __name__ == "__main__":
@@ -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()