Skip to content

Commit 5f678c3

Browse files
author
Cary Cheng
authored
Change api request retry strategy to use exponential backoff (#402)
1 parent 2d78d7b commit 5f678c3

File tree

5 files changed

+50
-15
lines changed

5 files changed

+50
-15
lines changed

boxsdk/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class API(object):
1313
UPLOAD_URL = 'https://upload.box.com/api/2.0'
1414
OAUTH2_API_URL = 'https://api.box.com/oauth2' # <https://developers.box.com/docs/#oauth-2>
1515
OAUTH2_AUTHORIZE_URL = 'https://account.box.com/api/oauth2/authorize' # <https://developers.box.com/docs/#oauth-2-authorize>
16+
MAX_RETRY_ATTEMPTS = 5
1617

1718

1819
class Client(object):

boxsdk/session/session.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from __future__ import unicode_literals, absolute_import
44

5+
import random
6+
import math
7+
58
from functools import partial
69
from logging import getLogger
710

@@ -17,6 +20,10 @@
1720

1821

1922
class Session(object):
23+
24+
_retry_randomization_factor = 0.5
25+
_retry_base_interval = 1
26+
2027
"""
2128
Box API session. Provides automatic retry of failed requests.
2229
"""
@@ -242,9 +249,13 @@ def with_default_network_request_kwargs(self, extra_network_parameters):
242249
kwargs['default_network_request_kwargs'].update(extra_network_parameters)
243250
return self.__class__(**kwargs)
244251

252+
# We updated our retry strategy to use exponential backoff instead of the header returned from the API response.
253+
# This is something we can remove in latter major bumps.
254+
# pylint: disable=unused-argument
245255
def _get_retry_after_time(self, attempt_number, retry_after_header):
246256
"""
247-
Get the amount of time to wait before retrying the API request.
257+
Get the amount of time to wait before retrying the API request, using the attempt number that failed to
258+
calculate the retry time for the next retry attempt.
248259
249260
If the Retry-After header is supplied, use it; otherwise, use exponential backoff
250261
For 202 Accepted (thumbnail or file not ready) and 429 (too many requests), retry later, after a delay
@@ -258,10 +269,11 @@ def _get_retry_after_time(self, attempt_number, retry_after_header):
258269
:return: Number of seconds to wait before retrying.
259270
:rtype: `Number`
260271
"""
261-
# pylint:disable=no-self-use
262-
if retry_after_header is not None:
263-
return float(retry_after_header)
264-
return 2 ** attempt_number
272+
min_randomization = 1 - self._retry_randomization_factor
273+
max_randomization = 1 + self._retry_randomization_factor
274+
randomization = (random.uniform(0, 1) * (max_randomization - min_randomization)) + min_randomization
275+
exponential = math.pow(2, attempt_number)
276+
return exponential * self._retry_base_interval * randomization
265277

266278
@staticmethod
267279
def _raise_on_unsuccessful_request(network_response, request):
@@ -362,7 +374,7 @@ def _prepare_and_send_request(
362374
while True:
363375
retry = self._get_retry_request_callable(network_response, attempt_number, request)
364376

365-
if retry is None or attempt_number >= 10:
377+
if retry is None or attempt_number >= API.MAX_RETRY_ATTEMPTS:
366378
break
367379

368380
attempt_number += 1

test/functional/test_rate_limits.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
from __future__ import unicode_literals
44

5+
from mock import patch
6+
57

68
def test_too_many_requests_causes_retry(box_client, mock_box, monkeypatch):
79
monkeypatch.setattr(mock_box, 'RATE_LIMIT_THRESHOLD', 1)
8-
box_client.folder('0').get()
9-
box_client.folder('0').get()
10+
with patch('random.uniform', return_value=1):
11+
box_client.folder('0').get()
12+
box_client.folder('0').get()
1013
assert len(mock_box.requests) == 6 # 3 auth requests, 2 real requests, and a retry

test/functional/test_recovery.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def test_client_stops_retrying_after_10_server_errors(box_client, mock_box, erro
6666
with pytest.raises(BoxAPIException) as exc_info:
6767
box_client.folder('0').get()
6868
assert exc_info.value.status == error_code
69-
assert len(mock_box.requests) == 14 # 3 auth requests, 1 try, and 10 retries
69+
assert len(mock_box.requests) == 9 # 3 auth requests, 1 try, and 5 retries
7070

7171

7272
@pytest.mark.parametrize('chaos', [html, xml])

test/unit/session/test_session.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from io import IOBase
77
from numbers import Number
88

9-
from mock import MagicMock, Mock, PropertyMock, call
9+
from mock import MagicMock, Mock, PropertyMock, call, patch
1010
import pytest
1111

1212
from boxsdk.auth.oauth2 import OAuth2
@@ -145,11 +145,12 @@ def test_box_session_retries_response_after_retry_after(
145145
mock_network_layer.request.side_effect = [retry_after_response, generic_successful_response]
146146
mock_network_layer.retry_after.side_effect = lambda delay, request, *args, **kwargs: request(*args, **kwargs)
147147

148-
box_response = test_method(box_session, url=test_url)
148+
with patch('random.uniform', return_value=0.68):
149+
box_response = test_method(box_session, url=test_url)
149150
assert box_response.status_code == 200
150151
assert len(mock_network_layer.retry_after.call_args_list) == 1
151152
assert isinstance(mock_network_layer.retry_after.call_args[0][0], Number)
152-
assert mock_network_layer.retry_after.call_args[0][0] == float(retry_after_response.headers['Retry-After'])
153+
assert round(mock_network_layer.retry_after.call_args[0][0], 4) == 1.18
153154

154155

155156
@pytest.mark.parametrize('test_method', [
@@ -172,16 +173,17 @@ def test_box_session_retries_request_after_server_error(
172173
mock_network_layer.request.side_effect = [server_error_response, server_error_response, generic_successful_response]
173174
mock_network_layer.retry_after.side_effect = lambda delay, request, *args, **kwargs: request(*args, **kwargs)
174175

175-
box_response = test_method(box_session, url=test_url)
176+
with patch('random.uniform', return_value=0.68):
177+
box_response = test_method(box_session, url=test_url)
176178
assert box_response.status_code == 200
177179
assert box_response.json() == generic_successful_response.json()
178180
assert box_response.ok == generic_successful_response.ok
179181
assert box_response.content == generic_successful_response.content
180182
assert len(mock_network_layer.retry_after.call_args_list) == 2
181183
assert isinstance(mock_network_layer.retry_after.call_args_list[0][0][0], Number)
182184
assert isinstance(mock_network_layer.retry_after.call_args_list[1][0][0], Number)
183-
assert mock_network_layer.retry_after.call_args_list[0][0][0] == 1
184-
assert mock_network_layer.retry_after.call_args_list[1][0][0] == 2
185+
assert round(mock_network_layer.retry_after.call_args_list[0][0][0], 4) == 1.18
186+
assert round(mock_network_layer.retry_after.call_args_list[1][0][0], 4) == 2.36
185187

186188

187189
def test_box_session_seeks_file_after_retry(box_session, mock_network_layer, server_error_response, generic_successful_response, test_url):
@@ -274,3 +276,20 @@ def test_session_uses_local_config(box_session, mock_network_layer, generic_succ
274276
box_session.api_config.BASE_API_URL = example_dot_com
275277
monkeypatch.setattr(API, 'BASE_API_URL', 'https://api.box.com')
276278
assert example_dot_com in box_session.get_url('foo', 'bar')
279+
280+
281+
@pytest.mark.parametrize(
282+
'attempt_number,retry_after_header,expected_result',
283+
[
284+
(0, '', 1.18),
285+
(1, '', 2.36),
286+
(2, '', 4.72),
287+
(3, '', 9.44),
288+
(4, '', 18.88),
289+
]
290+
)
291+
def test_get_retry_after_time(box_session, attempt_number, retry_after_header, expected_result):
292+
with patch('random.uniform', return_value=0.68):
293+
retry_time = box_session._get_retry_after_time(attempt_number, retry_after_header) # pylint: disable=protected-access
294+
retry_time = round(retry_time, 4)
295+
assert retry_time == expected_result

0 commit comments

Comments
 (0)