Skip to content

Commit b61ed09

Browse files
SG-35165 Retry on URLError (#342)
* Retry on URLError * Fix test * Reduce attempt * Use constant
1 parent 73193e3 commit b61ed09

File tree

3 files changed

+108
-35
lines changed

3 files changed

+108
-35
lines changed

shotgun_api3/shotgun.py

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,8 @@ class Shotgun(object):
500500
r"(\D?([01]\d|2[0-3])\D?([0-5]\d)\D?([0-5]\d)?\D?(\d{3})?)?$")
501501

502502
_MULTIPART_UPLOAD_CHUNK_SIZE = 20000000
503+
MAX_ATTEMPTS = 3 # Retries on failure
504+
BACKOFF = 0.75 # Seconds to wait before retry, times the attempt number
503505

504506
def __init__(self,
505507
base_url,
@@ -3431,10 +3433,7 @@ def _call_rpc(self, method, params, include_auth_params=True, first=False):
34313433
req_headers["locale"] = "auto"
34323434

34333435
attempt = 1
3434-
max_attempts = 4 # Three retries on failure
3435-
backoff = 0.75 # Seconds to wait before retry, times the attempt number
3436-
3437-
while attempt <= max_attempts:
3436+
while attempt <= self.MAX_ATTEMPTS:
34383437
http_status, resp_headers, body = self._make_call(
34393438
"POST",
34403439
self.config.api_path,
@@ -3452,9 +3451,9 @@ def _call_rpc(self, method, params, include_auth_params=True, first=False):
34523451
# We've seen some rare instances of PTR returning 502 for issues that
34533452
# appear to be caused by something internal to PTR. We're going to
34543453
# allow for limited retries for those specifically.
3455-
if attempt != max_attempts and e.errcode in [502, 504]:
3454+
if attempt != self.MAX_ATTEMPTS and e.errcode in [502, 504]:
34563455
LOG.debug("Got a 502 or 504 response. Waiting and retrying...")
3457-
time.sleep(float(attempt) * backoff)
3456+
time.sleep(float(attempt) * self.BACKOFF)
34583457
attempt += 1
34593458
continue
34603459
elif e.errcode == 403:
@@ -4143,28 +4142,31 @@ def _upload_data_to_storage(self, data, content_type, size, storage_url):
41434142
request.get_method = lambda: "PUT"
41444143

41454144
attempt = 1
4146-
max_attempts = 4 # Three retries on failure
4147-
backoff = 0.75 # Seconds to wait before retry, times the attempt number
4148-
4149-
while attempt <= max_attempts:
4145+
while attempt <= self.MAX_ATTEMPTS:
41504146
try:
41514147
result = self._make_upload_request(request, opener)
41524148

41534149
LOG.debug("Completed request to %s" % request.get_method())
41544150

41554151
except urllib.error.HTTPError as e:
4156-
if attempt != max_attempts and e.code in [500, 503]:
4152+
if attempt != self.MAX_ATTEMPTS and e.code in [500, 503]:
41574153
LOG.debug("Got a %s response. Waiting and retrying..." % e.code)
4158-
time.sleep(float(attempt) * backoff)
4154+
time.sleep(float(attempt) * self.BACKOFF)
41594155
attempt += 1
41604156
continue
41614157
elif e.code in [500, 503]:
41624158
raise ShotgunError("Got a %s response when uploading to %s: %s" % (e.code, storage_url, e))
41634159
else:
41644160
raise ShotgunError("Unanticipated error occurred uploading to %s: %s" % (storage_url, e))
4165-
4161+
except urllib.error.URLError as e:
4162+
LOG.debug("Got a '%s' response. Waiting and retrying..." % e)
4163+
time.sleep(float(attempt) * self.BACKOFF)
4164+
attempt += 1
4165+
continue
41664166
else:
41674167
break
4168+
else:
4169+
raise ShotgunError("Max attemps limit reached.")
41684170

41694171
etag = result.info()["Etag"]
41704172
LOG.debug("Part upload completed successfully.")
@@ -4250,19 +4252,28 @@ def _send_form(self, url, params):
42504252

42514253
opener = self._build_opener(FormPostHandler)
42524254

4253-
# Perform the request
4254-
try:
4255-
resp = opener.open(url, params)
4256-
result = resp.read()
4257-
# response headers are in str(resp.info()).splitlines()
4258-
except urllib.error.HTTPError as e:
4259-
if e.code == 500:
4260-
raise ShotgunError("Server encountered an internal error. "
4261-
"\n%s\n(%s)\n%s\n\n" % (url, self._sanitize_auth_params(params), e))
4262-
else:
4263-
raise ShotgunError("Unanticipated error occurred %s" % (e))
4255+
attempt = 1
4256+
while attempt <= self.MAX_ATTEMPTS:
4257+
# Perform the request
4258+
try:
4259+
resp = opener.open(url, params)
4260+
result = resp.read()
4261+
# response headers are in str(resp.info()).splitlines()
4262+
except urllib.error.URLError as e:
4263+
LOG.debug("Got a %s response. Waiting and retrying..." % e)
4264+
time.sleep(float(attempt) * self.BACKOFF)
4265+
attempt += 1
4266+
continue
4267+
except urllib.error.HTTPError as e:
4268+
if e.code == 500:
4269+
raise ShotgunError("Server encountered an internal error. "
4270+
"\n%s\n(%s)\n%s\n\n" % (url, self._sanitize_auth_params(params), e))
4271+
else:
4272+
raise ShotgunError("Unanticipated error occurred %s" % (e))
42644273

4265-
return six.ensure_text(result)
4274+
return six.ensure_text(result)
4275+
else:
4276+
raise ShotgunError("Max attemps limit reached.")
42664277

42674278

42684279
class CACertsHTTPSConnection(http_client.HTTPConnection):

tests/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ def _setup_mock(self, s3_status_code_error=503):
150150
# create the server caps directly to say we have the correct version
151151
self.sg._server_caps = ServerCapabilities(self.sg.config.server,
152152
{"version": [2, 4, 0]})
153+
# prevent waiting for backoff
154+
self.sg.BACKOFF = 0
153155

154156
def _mock_http(self, data, headers=None, status=None):
155157
"""Setup a mock response from the PTR server.

tests/test_client.py

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -438,9 +438,9 @@ def test_call_rpc(self):
438438
self._mock_http(d, status=(502, "bad gateway"))
439439
self.assertRaises(api.ProtocolError, self.sg._call_rpc, "list", a)
440440
self.assertEqual(
441-
4,
441+
self.sg.MAX_ATTEMPTS,
442442
self.sg._http_request.call_count,
443-
"Call is repeated up to 3 times",
443+
f"Call is repeated up to {self.sg.MAX_ATTEMPTS} times",
444444
)
445445

446446
# 504
@@ -449,9 +449,9 @@ def test_call_rpc(self):
449449
self._mock_http(d, status=(504, "gateway timeout"))
450450
self.assertRaises(api.ProtocolError, self.sg._call_rpc, "list", a)
451451
self.assertEqual(
452-
4,
452+
self.sg.MAX_ATTEMPTS,
453453
self.sg._http_request.call_count,
454-
"Call is repeated up to 3 times",
454+
f"Call is repeated up to {self.sg.MAX_ATTEMPTS} times",
455455
)
456456

457457
def test_upload_s3_503(self):
@@ -462,7 +462,6 @@ def test_upload_s3_503(self):
462462
storage_url = "http://foo.com/"
463463
path = os.path.abspath(os.path.expanduser(
464464
os.path.join(this_dir, "sg_logo.jpg")))
465-
max_attempts = 4 # Max retries to S3 server attempts
466465
# Expected HTTPError exception error message
467466
expected = "The server is currently down or to busy to reply." \
468467
"Please try again later."
@@ -474,8 +473,8 @@ def test_upload_s3_503(self):
474473
self.sg._upload_file_to_storage(path, storage_url)
475474
# Test the max retries attempt
476475
self.assertTrue(
477-
max_attempts == self.sg._make_upload_request.call_count,
478-
"Call is repeated up to 3 times")
476+
self.sg.MAX_ATTEMPTS == self.sg._make_upload_request.call_count,
477+
f"Call is repeated up to {self.sg.MAX_ATTEMPTS} times")
479478

480479
def test_upload_s3_500(self):
481480
"""
@@ -486,7 +485,6 @@ def test_upload_s3_500(self):
486485
storage_url = "http://foo.com/"
487486
path = os.path.abspath(os.path.expanduser(
488487
os.path.join(this_dir, "sg_logo.jpg")))
489-
max_attempts = 4 # Max retries to S3 server attempts
490488
# Expected HTTPError exception error message
491489
expected = "The server is currently down or to busy to reply." \
492490
"Please try again later."
@@ -498,8 +496,70 @@ def test_upload_s3_500(self):
498496
self.sg._upload_file_to_storage(path, storage_url)
499497
# Test the max retries attempt
500498
self.assertTrue(
501-
max_attempts == self.sg._make_upload_request.call_count,
502-
"Call is repeated up to 3 times")
499+
self.sg.MAX_ATTEMPTS == self.sg._make_upload_request.call_count,
500+
f"Call is repeated up to {self.sg.MAX_ATTEMPTS} times")
501+
502+
def test_upload_s3_urlerror__get_attachment_upload_info(self):
503+
"""
504+
Test URLError response is retried when invoking _send_form
505+
"""
506+
mock_opener = mock.Mock()
507+
mock_opener.return_value.open.side_effect = urllib.error.URLError(
508+
"[WinError 10054] An existing connection was forcibly closed by the remote host"
509+
)
510+
self.sg._build_opener = mock_opener
511+
this_dir, _ = os.path.split(__file__)
512+
path = os.path.abspath(
513+
os.path.expanduser(os.path.join(this_dir, "sg_logo.jpg"))
514+
)
515+
516+
with self.assertRaises(api.ShotgunError) as cm:
517+
self.sg._get_attachment_upload_info(False, path, False)
518+
519+
# Test the max retries attempt
520+
self.assertEqual(
521+
self.sg.MAX_ATTEMPTS,
522+
mock_opener.return_value.open.call_count,
523+
f"Call is repeated up to {self.sg.MAX_ATTEMPTS} times"
524+
)
525+
526+
# Test the exception message
527+
the_exception = cm.exception
528+
self.assertEqual(str(the_exception), "Max attemps limit reached.")
529+
530+
def test_upload_s3_urlerror__upload_to_storage(self):
531+
"""
532+
Test URLError response is retried when uploading to S3.
533+
"""
534+
self.sg._make_upload_request = mock.Mock(
535+
spec=api.Shotgun._make_upload_request,
536+
side_effect=urllib.error.URLError(
537+
"[Errno 104] Connection reset by peer",
538+
),
539+
)
540+
541+
this_dir, _ = os.path.split(__file__)
542+
storage_url = "http://foo.com/"
543+
path = os.path.abspath(
544+
os.path.expanduser(os.path.join(this_dir, "sg_logo.jpg"))
545+
)
546+
547+
# Test the Internal function that is used to upload each
548+
# data part in the context of multi-part uploads to S3, we
549+
# simulate the HTTPError exception raised with 503 status errors
550+
with self.assertRaises(api.ShotgunError) as cm:
551+
self.sg._upload_file_to_storage(path, storage_url)
552+
553+
# Test the max retries attempt
554+
self.assertEqual(
555+
self.sg.MAX_ATTEMPTS,
556+
self.sg._make_upload_request.call_count,
557+
f"Call is repeated up to {self.sg.MAX_ATTEMPTS} times"
558+
)
559+
560+
# Test the exception message
561+
the_exception = cm.exception
562+
self.assertEqual(str(the_exception), "Max attemps limit reached.")
503563

504564
def test_transform_data(self):
505565
"""Outbound data is transformed"""

0 commit comments

Comments
 (0)