Skip to content

Commit e640bae

Browse files
authored
SG-20154 Add Retries on 503 Errors when uploading to S3 in Shotgun python-api (#263)
* Add the 503 retries in the internal function who maker the S3 uploads. * Mock the S3 response with a expected HTTPError exception, for test 503 responses are retried when uploading to S3. * Adding side_effect parameter to the mock object. * Add comment in the _setup_mock() _make_upload_request definition. * Rename test and add expected HTTPError exception error message to the assertion.
1 parent 6a48fb9 commit e640bae

File tree

3 files changed

+78
-15
lines changed

3 files changed

+78
-15
lines changed

shotgun_api3/shotgun.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3588,6 +3588,18 @@ def _http_request(self, verb, path, body, headers):
35883588

35893589
return (http_status, resp_headers, resp_body)
35903590

3591+
def _make_upload_request(self, request, opener):
3592+
"""
3593+
Open the given request object, return the
3594+
response, raises URLError on protocol errors.
3595+
"""
3596+
try:
3597+
result = opener.open(request)
3598+
3599+
except urllib.error.HTTPError:
3600+
raise
3601+
return result
3602+
35913603
def _parse_http_status(self, status):
35923604
"""
35933605
Parse the status returned from the http request.
@@ -4049,21 +4061,40 @@ def _upload_data_to_storage(self, data, content_type, size, storage_url):
40494061
:returns: upload url.
40504062
:rtype: str
40514063
"""
4052-
try:
4053-
opener = self._build_opener(urllib.request.HTTPHandler)
4064+
opener = self._build_opener(urllib.request.HTTPHandler)
4065+
4066+
request = urllib.request.Request(storage_url, data=data)
4067+
request.add_header("Content-Type", content_type)
4068+
request.add_header("Content-Length", size)
4069+
request.get_method = lambda: "PUT"
4070+
4071+
attempt = 1
4072+
max_attempts = 4 # Three retries on failure
4073+
backoff = 0.75 # Seconds to wait before retry, times the attempt number
4074+
4075+
while attempt <= max_attempts:
4076+
try:
4077+
result = self._make_upload_request(request, opener)
4078+
4079+
LOG.debug("Completed request to %s" % request.get_method())
4080+
4081+
except urllib.error.HTTPError as e:
4082+
if e.code == 500:
4083+
raise ShotgunError("Server encountered an internal error.\n%s\n%s\n\n" % (storage_url, e))
4084+
elif attempt != max_attempts and e.code == 503:
4085+
LOG.debug("Got a 503 response. Waiting and retrying...")
4086+
time.sleep(float(attempt) * backoff)
4087+
attempt += 1
4088+
continue
4089+
else:
4090+
if e.code == 503:
4091+
raise ShotgunError("Got a 503 response when uploading to %s: %s" % (storage_url, e))
4092+
raise ShotgunError("Unanticipated error occurred uploading to %s: %s" % (storage_url, e))
40544093

4055-
request = urllib.request.Request(storage_url, data=data)
4056-
request.add_header("Content-Type", content_type)
4057-
request.add_header("Content-Length", size)
4058-
request.get_method = lambda: "PUT"
4059-
result = opener.open(request)
4060-
etag = result.info()["Etag"]
4061-
except urllib.error.HTTPError as e:
4062-
if e.code == 500:
4063-
raise ShotgunError("Server encountered an internal error.\n%s\n%s\n\n" % (storage_url, e))
40644094
else:
4065-
raise ShotgunError("Unanticipated error occurred uploading to %s: %s" % (storage_url, e))
4095+
break
40664096

4097+
etag = result.info()["Etag"]
40674098
LOG.debug("Part upload completed successfully.")
40684099
return etag
40694100

tests/base.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from shotgun_api3.shotgun import json
1010
from shotgun_api3.shotgun import ServerCapabilities
1111
from shotgun_api3.lib import six
12+
from shotgun_api3.lib.six.moves import urllib
1213

1314
if six.PY2:
1415
from shotgun_api3.lib.six.moves.configparser import SafeConfigParser as ConfigParser
@@ -128,7 +129,17 @@ def _setup_mock(self):
128129
# eaiser than mocking the http connection + response
129130
self.sg._http_request = mock.Mock(spec=api.Shotgun._http_request,
130131
return_value=((200, "OK"), {}, None))
131-
132+
# Replace the function used to make the final call to the S3 server, and simulate
133+
# the exception HTTPError raised with 503 status errors
134+
self.sg._make_upload_request = mock.Mock(spec=api.Shotgun._make_upload_request,
135+
side_effect = urllib.error.HTTPError(
136+
"url",
137+
503,
138+
"The server is currently down or to busy to reply."
139+
"Please try again later.",
140+
{},
141+
None
142+
))
132143
# also replace the function that is called to get the http connection
133144
# to avoid calling the server. OK to return a mock as we will not use
134145
# it

tests/test_client.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
from shotgun_api3.shotgun import ServerCapabilities, SG_TIMEZONE
3838
from . import base
3939

40-
4140
if six.PY3:
4241
from base64 import encodebytes as base64encode
4342
else:
@@ -196,7 +195,6 @@ def test_split_url(self):
196195
sg = api.Shotgun("https://ci.shotgunstudio.com",
197196
"foo", "bar", connect=False)
198197

199-
200198
base_url = "https://ci.shotgunstudio.com"
201199
expected_server = "ci.shotgunstudio.com"
202200
expected_auth = None
@@ -439,6 +437,29 @@ def test_call_rpc(self):
439437
self._mock_http(d, status=(502, "bad gateway"))
440438
self.assertRaises(api.ProtocolError, self.sg._call_rpc, "list", a)
441439

440+
def test_upload_s3(self):
441+
"""
442+
Test 503 response is retried when uploading to S3.
443+
"""
444+
this_dir, _ = os.path.split(__file__)
445+
storage_url = "http://foo.com/"
446+
path = os.path.abspath(os.path.expanduser(
447+
os.path.join(this_dir, "sg_logo.jpg")))
448+
max_attempts = 4 # Max retries to S3 server attempts
449+
# Expected HTTPError exception error message
450+
expected = "The server is currently down or to busy to reply." \
451+
"Please try again later."
452+
453+
# Test the Internal function that is used to upload each
454+
# data part in the context of multi-part uploads to S3, we
455+
# simulate the HTTPError exception raised with 503 status errors
456+
with self.assertRaises(api.ShotgunError, msg=expected):
457+
self.sg._upload_file_to_storage(path, storage_url)
458+
# Test the max retries attempt
459+
self.assertTrue(
460+
max_attempts == self.sg._make_upload_request.call_count,
461+
"Call is repeated up to 3 times")
462+
442463
def test_transform_data(self):
443464
"""Outbound data is transformed"""
444465
timestamp = time.time()

0 commit comments

Comments
 (0)