Skip to content

Commit 5eae728

Browse files
feat: add configurable retry with exponential backoff for RemoteDisconnect errors (#1316)
* feat: added configurable retry with exponential backoff for RemoteDisconnect errors Two new parameters to control retry behavior when connection is aborted: - remote_disconnect_max_retries: max retry attempts (default: 5) - remote_disconnect_retry_delay: initial delay in seconds (default: 1) * Added configurable parameters for async operation polling: - async_polling_initial_delay: initial polling delay (default: 0.1s) - async_polling_max_delay: maximum polling delay cap (default: 1.0s) - async_polling_backoff_factor: multiplier for exponential backoff (default: 2) * added extra test cases for the new parameters * added remote_disconnect_max_delay: maximum delay cap in seconds (default: 30) * added new arguments on TM1Service
1 parent 4b09446 commit 5eae728

3 files changed

Lines changed: 185 additions & 58 deletions

File tree

TM1py/Services/RestService.py

Lines changed: 100 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ def __init__(self, **kwargs):
125125
- **impersonate** (str): Name of user to impersonate.
126126
- **re_connect_on_session_timeout** (bool): Attempt to reconnect once if session is timed out.
127127
- **re_connect_on_remote_disconnect** (bool): Attempt to reconnect once if connection is aborted by remote end.
128+
- **remote_disconnect_max_retries** (int): Maximum number of retry attempts after remote disconnect (default: 5).
129+
- **remote_disconnect_retry_delay** (float): Initial delay in seconds before first retry attempt (default: 1).
130+
- **remote_disconnect_max_delay** (float): Maximum delay cap in seconds between retry attempts (default: 30).
131+
- **remote_disconnect_backoff_factor** (float): Multiplier for exponential backoff between retry attempts (default: 2).
132+
- **async_polling_initial_delay** (float): Initial polling delay in seconds for async operations (default: 0.1).
133+
- **async_polling_max_delay** (float): Maximum polling delay cap in seconds for async operations (default: 1.0).
134+
- **async_polling_backoff_factor** (float): Multiplier for exponential backoff in async polling (default: 2).
128135
- **proxies** (dict): Dictionary with proxies, e.g. {'http': 'http://proxy.example.com:8080', 'https': 'http://secureproxy.example.com:8090'}.
129136
- **ssl_context**: User-defined SSL context.
130137
- **cert** (str|tuple): (Optional) If string, path to SSL client cert file (.pem). If tuple, ('cert', 'key') pair.
@@ -158,6 +165,13 @@ def __init__(self, **kwargs):
158165
self._pool_connections = int(kwargs.get("pool_connections", self.DEFAULT_POOL_CONNECTIONS))
159166
self._re_connect_on_session_timeout = kwargs.get("re_connect_on_session_timeout", True)
160167
self._re_connect_on_remote_disconnect = kwargs.get("re_connect_on_remote_disconnect", True)
168+
self._remote_disconnect_max_retries = int(kwargs.get("remote_disconnect_max_retries", 5))
169+
self._remote_disconnect_retry_delay = float(kwargs.get("remote_disconnect_retry_delay", 1))
170+
self._remote_disconnect_max_delay = float(kwargs.get("remote_disconnect_max_delay", 30))
171+
self._remote_disconnect_backoff_factor = float(kwargs.get("remote_disconnect_backoff_factor", 2))
172+
self._async_polling_initial_delay = float(kwargs.get("async_polling_initial_delay", 0.1))
173+
self._async_polling_max_delay = float(kwargs.get("async_polling_max_delay", 1.0))
174+
self._async_polling_backoff_factor = float(kwargs.get("async_polling_backoff_factor", 2))
161175
# is retrieved on demand and then cached
162176
self._sandboxing_disabled = None
163177
# optional verbose logging to stdout
@@ -390,7 +404,7 @@ def _poll_async_response(self, async_id: str, timeout: float, cancel_at_timeout:
390404
"""
391405
Poll for async operation completion
392406
"""
393-
for wait in RestService.wait_time_generator(timeout):
407+
for wait in self.wait_time_generator(timeout):
394408
response = self.retrieve_async_response(async_id)
395409
if response.status_code in [200, 201]:
396410
return response
@@ -431,52 +445,77 @@ def _handle_remote_disconnect(
431445
**kwargs,
432446
):
433447
"""
434-
Handle remote disconnect errors with reconnection and retry logic
448+
Handle remote disconnect errors with reconnection and retry logic using exponential backoff.
449+
450+
Retries up to `remote_disconnect_max_retries` times with capped exponential backoff delay.
451+
The delay is calculated as: min(delay * backoff_factor^(attempt-1), max_delay).
435452
"""
436-
warnings.warn(f"Connection aborted due to remote disconnect. Attempting to reconnect: {original_error}")
453+
warnings.warn(f"Connection aborted due to remote disconnect: {original_error}")
437454

438-
try:
439-
# Reconnect
440-
self._manage_http_adapter()
441-
self.connect()
455+
for attempt in range(1, self._remote_disconnect_max_retries + 1):
456+
# Calculate delay with exponential backoff: delay * backoff_factor^(attempt-1), capped at max_delay
457+
current_delay = min(
458+
self._remote_disconnect_retry_delay * (self._remote_disconnect_backoff_factor ** (attempt - 1)),
459+
self._remote_disconnect_max_delay
460+
)
442461

443-
# Only retry if idempotent
444-
if not idempotent:
445-
warnings.warn(
446-
f"Successfully reconnected but not retrying {method.upper()} request (idempotent={idempotent})"
447-
)
448-
raise original_error
462+
warnings.warn(
463+
f"Retry attempt {attempt}/{self._remote_disconnect_max_retries} "
464+
f"after {current_delay:.1f}s delay..."
465+
)
449466

450-
warnings.warn(f"Successfully reconnected. Retrying {method.upper()} request...")
467+
time.sleep(current_delay)
451468

452-
# Retry the request using the same execution path
453-
if not async_requests_mode:
454-
response = self._execute_sync_request(method=method, url=url, data=data, timeout=timeout, **kwargs)
455-
else:
456-
response = self._execute_async_request(
457-
method=method,
458-
url=url,
459-
data=data,
460-
timeout=timeout,
461-
cancel_at_timeout=cancel_at_timeout,
462-
return_async_id=return_async_id,
463-
**kwargs,
464-
)
469+
try:
470+
# Reconnect
471+
self._manage_http_adapter()
472+
self.connect()
473+
474+
# Only retry if idempotent
475+
if not idempotent:
476+
warnings.warn(
477+
f"Successfully reconnected but not retrying {method.upper()} request (idempotent={idempotent})"
478+
)
479+
raise original_error
465480

466-
# Verify and encode response
467-
self.verify_response(response=response)
468-
response.encoding = encoding
469-
return response
481+
warnings.warn(f"Successfully reconnected. Retrying {method.upper()} request...")
482+
483+
# Retry the request using the same execution path
484+
if not async_requests_mode:
485+
response = self._execute_sync_request(method=method, url=url, data=data, timeout=timeout, **kwargs)
486+
else:
487+
response = self._execute_async_request(
488+
method=method,
489+
url=url,
490+
data=data,
491+
timeout=timeout,
492+
cancel_at_timeout=cancel_at_timeout,
493+
return_async_id=return_async_id,
494+
**kwargs,
495+
)
496+
497+
# Verify and encode response
498+
self.verify_response(response=response)
499+
response.encoding = encoding
500+
return response
470501

471-
except TM1pyTimeout:
472-
# Re-raise timeout exceptions as-is
473-
raise
474-
except TM1pyRestException:
475-
# Re-raise TM1 exceptions as-is
476-
raise
477-
except Exception as retry_error:
478-
warnings.warn(f"Failed to reconnect or retry after remote disconnect: {retry_error}")
479-
raise original_error
502+
except TM1pyTimeout:
503+
# Re-raise timeout exceptions as-is
504+
raise
505+
except TM1pyRestException:
506+
# Re-raise TM1 exceptions as-is
507+
raise
508+
except Exception as retry_error:
509+
warnings.warn(
510+
f"Retry attempt {attempt}/{self._remote_disconnect_max_retries} failed: {retry_error}"
511+
)
512+
continue
513+
514+
# All retries exhausted
515+
warnings.warn(
516+
f"All {self._remote_disconnect_max_retries} retry attempts failed after remote disconnect"
517+
)
518+
raise original_error
480519

481520
def connect(self):
482521
if "session_id" in self._kwargs:
@@ -1257,17 +1296,30 @@ def build_response_from_binary_response(data: bytes) -> Response:
12571296

12581297
return requests_response
12591298

1260-
@staticmethod
1261-
def wait_time_generator(timeout: int):
1262-
yield 0.1
1263-
yield 0.3
1264-
yield 0.6
1299+
def wait_time_generator(self, timeout: float):
1300+
"""
1301+
Generate wait times for async polling with capped exponential backoff.
1302+
1303+
Uses configurable parameters:
1304+
- async_polling_initial_delay: Starting delay
1305+
- async_polling_max_delay: Maximum delay cap
1306+
- async_polling_backoff_factor: Multiplier for each iteration
1307+
1308+
Default behavior (0.1s initial, 1.0s max, 2x factor) produces:
1309+
0.1s -> 0.2s -> 0.4s -> 0.8s -> 1.0s -> 1.0s -> ...
1310+
"""
1311+
delay = self._async_polling_initial_delay
1312+
elapsed = 0.0
1313+
12651314
if timeout:
1266-
for _ in range(1, int(timeout)):
1267-
yield 1
1315+
while elapsed < timeout:
1316+
yield delay
1317+
elapsed += delay
1318+
delay = min(delay * self._async_polling_backoff_factor, self._async_polling_max_delay)
12681319
else:
12691320
while True:
1270-
yield 1
1321+
yield delay
1322+
delay = min(delay * self._async_polling_backoff_factor, self._async_polling_max_delay)
12711323

12721324
def _determine_ssl_based_on_base_url(self) -> bool:
12731325
if self._base_url.startswith("https"):

TM1py/Services/TM1Service.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,15 @@ def __init__(self, **kwargs):
7979
- **integrated_login_host** (str): Host name for Service Principal Name.
8080
- **integrated_login_delegate** (bool): Delegate user credentials to the server.
8181
- **impersonate** (str): Name of the user to impersonate.
82-
- **re_connect_on_session_timeout** (bool): Attempt to reconnect if the session times out.
83-
- **re_connect_on_remote_disconnect** (bool): Attempt to reconnect if the connection is aborted.
82+
- **re_connect_on_session_timeout** (bool): Attempt to reconnect once if session is timed out.
83+
- **re_connect_on_remote_disconnect** (bool): Attempt to reconnect once if connection is aborted by remote end.
84+
- **remote_disconnect_max_retries** (int): Maximum number of retry attempts after remote disconnect (default: 5).
85+
- **remote_disconnect_retry_delay** (float): Initial delay in seconds before first retry attempt (default: 1).
86+
- **remote_disconnect_max_delay** (float): Maximum delay cap in seconds between retry attempts (default: 30).
87+
- **remote_disconnect_backoff_factor** (float): Multiplier for exponential backoff between retry attempts (default: 2).
88+
- **async_polling_initial_delay** (float): Initial polling delay in seconds for async operations (default: 0.1).
89+
- **async_polling_max_delay** (float): Maximum polling delay cap in seconds for async operations (default: 1.0).
90+
- **async_polling_backoff_factor** (float): Multiplier for exponential backoff in async polling (default: 2).
8491
- **proxies** (dict): Dictionary of proxies, e.g., {'http': 'http://proxy.example.com:8080'}.
8592
- **ssl_context**: User-defined SSL context.
8693
- **cert** (str or tuple): Path to SSL client cert file or ('cert', 'key') pair.

Tests/RestService_test.py

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,88 @@ def test_is_connected(self):
2424
self.assertTrue(self.tm1._tm1_rest.is_connected())
2525

2626
def test_wait_time_generator_with_float_timeout(self):
27-
self.assertEqual([0.1, 0.3, 0.6, 1, 1, 1, 1, 1, 1, 1, 1, 1], list(self.tm1._tm1_rest.wait_time_generator(10.0)))
28-
self.assertEqual(sum(self.tm1._tm1_rest.wait_time_generator(10)), 10)
27+
# With default params (0.1s initial, 1.0s max, 2x factor): 0.1 -> 0.2 -> 0.4 -> 0.8 -> 1.0 -> 1.0...
28+
expected = [0.1, 0.2, 0.4, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
29+
self.assertEqual(expected, list(self.tm1._tm1_rest.wait_time_generator(10.0)))
30+
self.assertEqual(10.5, sum(self.tm1._tm1_rest.wait_time_generator(10.0)))
2931

3032
def test_wait_time_generator_with_timeout(self):
31-
self.assertEqual([0.1, 0.3, 0.6, 1, 1, 1, 1, 1, 1, 1, 1, 1], list(self.tm1._tm1_rest.wait_time_generator(10)))
32-
self.assertEqual(sum(self.tm1._tm1_rest.wait_time_generator(10)), 10)
33+
# With default params (0.1s initial, 1.0s max, 2x factor): 0.1 -> 0.2 -> 0.4 -> 0.8 -> 1.0 -> 1.0...
34+
expected = [0.1, 0.2, 0.4, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
35+
self.assertEqual(expected, list(self.tm1._tm1_rest.wait_time_generator(10)))
36+
self.assertEqual(10.5, sum(self.tm1._tm1_rest.wait_time_generator(10)))
3337

3438
def test_wait_time_generator_without_timeout(self):
39+
# With default params (0.1s initial, 1.0s max, 2x factor): 0.1 -> 0.2 -> 0.4 -> 0.8 -> 1.0 -> 1.0...
3540
generator = self.tm1._tm1_rest.wait_time_generator(None)
3641
self.assertEqual(0.1, next(generator))
37-
self.assertEqual(0.3, next(generator))
38-
self.assertEqual(0.6, next(generator))
39-
self.assertEqual(1, next(generator))
40-
self.assertEqual(1, next(generator))
42+
self.assertEqual(0.2, next(generator))
43+
self.assertEqual(0.4, next(generator))
44+
self.assertEqual(0.8, next(generator))
45+
self.assertEqual(1.0, next(generator))
46+
self.assertEqual(1.0, next(generator))
47+
48+
def test_wait_time_generator_custom_max_delay(self):
49+
# Test with custom max_delay for long-running operations
50+
original_max_delay = self.tm1._tm1_rest._async_polling_max_delay
51+
try:
52+
self.tm1._tm1_rest._async_polling_max_delay = 30.0
53+
# With 0.1s initial, 30s max, 2x factor: 0.1 -> 0.2 -> 0.4 -> 0.8 -> 1.6 -> 3.2 -> 6.4 -> 12.8 -> 25.6 -> 30.0...
54+
generator = self.tm1._tm1_rest.wait_time_generator(None)
55+
self.assertEqual(0.1, next(generator))
56+
self.assertEqual(0.2, next(generator))
57+
self.assertEqual(0.4, next(generator))
58+
self.assertEqual(0.8, next(generator))
59+
self.assertEqual(1.6, next(generator))
60+
self.assertEqual(3.2, next(generator))
61+
self.assertEqual(6.4, next(generator))
62+
self.assertEqual(12.8, next(generator))
63+
self.assertEqual(25.6, next(generator))
64+
self.assertEqual(30.0, next(generator))
65+
self.assertEqual(30.0, next(generator))
66+
finally:
67+
self.tm1._tm1_rest._async_polling_max_delay = original_max_delay
68+
69+
def test_wait_time_generator_custom_backoff_factor(self):
70+
# Test with custom backoff factor (3x instead of 2x)
71+
original_factor = self.tm1._tm1_rest._async_polling_backoff_factor
72+
try:
73+
self.tm1._tm1_rest._async_polling_backoff_factor = 3.0
74+
# With 0.1s initial, 1.0s max, 3x factor: 0.1 -> 0.3 -> 0.9 -> 1.0 -> 1.0...
75+
generator = self.tm1._tm1_rest.wait_time_generator(None)
76+
self.assertEqual(0.1, next(generator))
77+
self.assertAlmostEqual(0.3, next(generator), places=5)
78+
self.assertAlmostEqual(0.9, next(generator), places=5)
79+
self.assertEqual(1.0, next(generator))
80+
self.assertEqual(1.0, next(generator))
81+
finally:
82+
self.tm1._tm1_rest._async_polling_backoff_factor = original_factor
83+
84+
def test_wait_time_generator_custom_initial_delay(self):
85+
# Test with custom initial delay
86+
original_initial = self.tm1._tm1_rest._async_polling_initial_delay
87+
try:
88+
self.tm1._tm1_rest._async_polling_initial_delay = 0.5
89+
# With 0.5s initial, 1.0s max, 2x factor: 0.5 -> 1.0 -> 1.0...
90+
generator = self.tm1._tm1_rest.wait_time_generator(None)
91+
self.assertEqual(0.5, next(generator))
92+
self.assertEqual(1.0, next(generator))
93+
self.assertEqual(1.0, next(generator))
94+
finally:
95+
self.tm1._tm1_rest._async_polling_initial_delay = original_initial
96+
97+
def test_default_remote_disconnect_parameters(self):
98+
# Verify default values for remote disconnect retry parameters
99+
self.assertEqual(5, self.tm1._tm1_rest._remote_disconnect_max_retries)
100+
self.assertEqual(1.0, self.tm1._tm1_rest._remote_disconnect_retry_delay)
101+
self.assertEqual(30.0, self.tm1._tm1_rest._remote_disconnect_max_delay)
102+
self.assertEqual(2.0, self.tm1._tm1_rest._remote_disconnect_backoff_factor)
103+
104+
def test_default_async_polling_parameters(self):
105+
# Verify default values for async polling parameters
106+
self.assertEqual(0.1, self.tm1._tm1_rest._async_polling_initial_delay)
107+
self.assertEqual(1.0, self.tm1._tm1_rest._async_polling_max_delay)
108+
self.assertEqual(2.0, self.tm1._tm1_rest._async_polling_backoff_factor)
41109

42110
def test_build_response_from_async_response_ok(self):
43111
response_content = (

0 commit comments

Comments
 (0)