Skip to content

Commit 5b65f68

Browse files
author
Anatoli Kurtsevich
authored
NCDNTS-1079 Added a retry mechanism for http failures (#132)
* added a retry mechanism for http failures * bumped release version * removed unused import * changed comment for _urlopen_with_retry * removed logger configuration from unit tests * formatted tests * addded retry config to context and defaulted it to 0, updated retry mechanism not to count the original call as one of the attempts counted towards specified retries count * updated CHANGELOG * updated logging messages * removed timeout between retries, updated exception text * rethrowing the last exception if no retries are successful
1 parent a7f563b commit 5b65f68

File tree

5 files changed

+104
-4
lines changed

5 files changed

+104
-4
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v0.6.14
4+
5+
* Added a retry mechanism for HTTP failures raised as `URLError`.
6+
* Defaults to `0` retries
7+
* Configurable through `Context` (example `Context(**cfg, retries=3)` to set retries to 3)
8+
39
## v0.6.13
410

511
* Improved debug logging for `exec`.

railib/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
__version_info__ = (0, 6, 13)
15+
__version_info__ = (0, 6, 14)
1616
__version__ = ".".join(map(str, __version_info__))

railib/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,9 @@ def __init__(
147147
region: str = None,
148148
credentials=None,
149149
audience: str = None,
150+
retries: int = 0,
150151
):
151-
super().__init__(region=region, credentials=credentials)
152+
super().__init__(region=region, credentials=credentials, retries=retries)
152153
self.host = host
153154
self.port = port or "443"
154155
self.scheme = scheme or "https"

railib/rest.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
import json
1818
import logging
1919
from os import path
20+
import socket
21+
import time
22+
from urllib.error import URLError
2023
from urllib.parse import urlencode, urlsplit, quote
2124
from urllib.request import Request, urlopen
2225

@@ -45,10 +48,14 @@
4548

4649
# Context contains the state required to make rAI REST API calls.
4750
class Context(object):
48-
def __init__(self, region: str = None, credentials: Credentials = None):
51+
def __init__(self, region: str = None, credentials: Credentials = None, retries: int = 0):
52+
if retries < 0:
53+
raise ValueError("Retries must be a non-negative integer")
54+
4955
self.region = region or "us-east"
5056
self.credentials = credentials
5157
self.service = "transaction"
58+
self.retries = retries
5259

5360

5461
# Answers if the keys of the passed dict contain a case insensitive match
@@ -211,6 +218,27 @@ def _authenticate(ctx: Context, req: Request) -> Request:
211218
raise Exception("unknown credential type")
212219

213220

221+
# Issues an HTTP request and retries if failed due to URLError.
222+
def _urlopen_with_retry(req: Request, retries: int = 0):
223+
if retries < 0:
224+
raise ValueError("Retries must be a non-negative integer")
225+
226+
attempts = retries + 1
227+
228+
for attempt in range(attempts):
229+
try:
230+
return urlopen(req)
231+
except URLError as e:
232+
if isinstance(e.reason, socket.timeout):
233+
logger.warning(f"Timeout occurred (attempt {attempt + 1}/{attempts}): {req.full_url}")
234+
else:
235+
logger.warning(f"URLError occurred {e.reason} (attempt {attempt + 1}/{attempts}): {req.full_url}")
236+
237+
if attempt == attempts - 1:
238+
logger.error(f"Failed to connect to {req.full_url} after {attempts} attempt{'s' if attempts > 1 else ''}")
239+
raise e
240+
241+
214242
# Issues an RAI REST API request, and returns response contents if successful.
215243
def request(ctx: Context, method: str, url: str, headers={}, data=None, **kwargs):
216244
headers = _default_headers(url, headers)
@@ -220,7 +248,7 @@ def request(ctx: Context, method: str, url: str, headers={}, data=None, **kwargs
220248
req = Request(method=method, url=url, headers=headers, data=data)
221249
req = _authenticate(ctx, req)
222250
_print_request(req)
223-
rsp = urlopen(req)
251+
rsp = _urlopen_with_retry(req, ctx.retries)
224252

225253
# logging
226254
content_type = headers["Content-Type"] if "Content-Type" in headers else ""

test/test_unit.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import socket
12
import unittest
3+
from unittest.mock import patch, MagicMock
4+
from urllib.error import URLError
5+
from urllib.request import Request
26

37
from railib import api
8+
from railib.rest import _urlopen_with_retry
49

510

611
class TestPolling(unittest.TestCase):
@@ -23,5 +28,65 @@ def test_validation(self):
2328
api.poll_with_specified_overhead(lambda: True, overhead_rate=0.1, timeout=1, max_tries=1)
2429

2530

31+
@patch('railib.rest.urlopen')
32+
class TestURLOpenWithRetry(unittest.TestCase):
33+
34+
def test_successful_response(self, mock_urlopen):
35+
# Set up the mock urlopen to return a successful response
36+
mock_response = MagicMock()
37+
mock_urlopen.return_value = mock_response
38+
mock_response.read.return_value = b'Hello, World!'
39+
40+
req = Request('https://example.com')
41+
42+
response = _urlopen_with_retry(req)
43+
self.assertEqual(response.read(), b'Hello, World!')
44+
mock_urlopen.assert_called_once_with(req)
45+
46+
response = _urlopen_with_retry(req, 3)
47+
self.assertEqual(response.read(), b'Hello, World!')
48+
self.assertEqual(mock_urlopen.call_count, 2)
49+
50+
def test_negative_retries(self, _):
51+
req = Request('https://example.com')
52+
53+
with self.assertRaises(Exception) as e:
54+
_urlopen_with_retry(req, -1)
55+
56+
self.assertIn("Retries must be a non-negative integer", str(e.exception))
57+
58+
def test_timeout_retry(self, mock_urlopen):
59+
# Set up the mock urlopen to raise a socket timeout error
60+
mock_urlopen.side_effect = URLError(socket.timeout())
61+
62+
req = Request('https://example.com')
63+
with self.assertLogs() as log:
64+
with self.assertRaises(Exception):
65+
_urlopen_with_retry(req, 2)
66+
67+
self.assertEqual(mock_urlopen.call_count, 3) # Expect 1 original call and 2 calls for retries
68+
self.assertEqual(len(log.output), 4) # Expect 3 log messages for retries and 1 for failure to connect
69+
self.assertIn('Timeout occurred', log.output[0])
70+
self.assertIn('Timeout occurred', log.output[1])
71+
self.assertIn('Timeout occurred', log.output[2])
72+
self.assertIn('Failed to connect to', log.output[3])
73+
74+
def test_other_error_retry(self, mock_urlopen):
75+
# Set up the mock urlopen to raise a non-timeout URLError
76+
mock_urlopen.side_effect = URLError('Some other error')
77+
78+
req = Request('https://example.com')
79+
with self.assertLogs() as log:
80+
with self.assertRaises(Exception):
81+
_urlopen_with_retry(req, retries=2)
82+
83+
self.assertEqual(mock_urlopen.call_count, 3) # Expect 3 calls for retries
84+
self.assertEqual(len(log.output), 4) # Expect 3 log messages for retries and 1 for failure to connect
85+
self.assertIn('URLError occurred', log.output[0])
86+
self.assertIn('URLError occurred', log.output[1])
87+
self.assertIn('URLError occurred', log.output[2])
88+
self.assertIn('Failed to connect to', log.output[3])
89+
90+
2691
if __name__ == '__main__':
2792
unittest.main()

0 commit comments

Comments
 (0)