Skip to content

Commit 2e6df15

Browse files
Fix client bugs: refresh retry, error parsing, clone user-agent, scope validation (#535)
- Retry the token-refresh POST on 5xx (InternalServerError) using the same exponential backoff as request_json_string_with_retry, honoring max_retries_on_error. Previously a 5xx during refresh bypassed retries entirely (#518). - raise_dropbox_error_for_resp: use res.json().get('error') and catch AttributeError so a 400 body lacking the "error" key raises BadInputError instead of KeyError (#527). - clone(): pass self._raw_user_agent instead of self._user_agent so cloned clients don't get the OfficialDropboxPythonSDKv2 suffix appended twice (#526). - Reorder scope validation to check isinstance before len(), so a non-list scope raises BadInputException instead of TypeError, in the client constructor, refresh_access_token, and DropboxOAuth2FlowBase (#526). Adds unit tests for each fix (reproduced as failing tests first).
1 parent b2476f9 commit 2e6df15

3 files changed

Lines changed: 106 additions & 9 deletions

File tree

dropbox/dropbox_client.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ def __init__(self,
195195
if oauth2_refresh_token and not app_key:
196196
raise BadInputException("app_key is required to refresh tokens")
197197

198-
if scope is not None and (len(scope) == 0 or not isinstance(scope, list)):
198+
if scope is not None and (not isinstance(scope, list) or len(scope) == 0):
199199
raise BadInputException("Scope list must be of type list")
200200

201201
self._oauth2_access_token = oauth2_access_token
@@ -261,7 +261,7 @@ def clone(
261261
oauth2_access_token or self._oauth2_access_token,
262262
max_retries_on_error or self._max_retries_on_error,
263263
max_retries_on_rate_limit or self._max_retries_on_rate_limit,
264-
user_agent or self._user_agent,
264+
user_agent or self._raw_user_agent,
265265
session or self._session,
266266
headers or self._headers,
267267
timeout or self._timeout,
@@ -378,7 +378,7 @@ def refresh_access_token(self, host=API_HOST, scope=None):
378378
:param scope: list of permission scopes for access token
379379
:return:
380380
"""
381-
if scope is not None and (len(scope) == 0 or not isinstance(scope, list)):
381+
if scope is not None and (not isinstance(scope, list) or len(scope) == 0):
382382
raise BadInputException("Scope list must be of type list")
383383

384384
if not (self._oauth2_refresh_token and self._app_key):
@@ -401,8 +401,25 @@ def refresh_access_token(self, host=API_HOST, scope=None):
401401
timeout = DEFAULT_TIMEOUT
402402
if self._timeout:
403403
timeout = self._timeout
404-
res = self._session.post(url, data=body, timeout=timeout)
405-
self.raise_dropbox_error_for_resp(res)
404+
405+
attempt = 0
406+
while True:
407+
res = self._session.post(url, data=body, timeout=timeout)
408+
try:
409+
self.raise_dropbox_error_for_resp(res)
410+
break
411+
except InternalServerError as e:
412+
attempt += 1
413+
if attempt <= self._max_retries_on_error:
414+
# Use exponential backoff, matching request_json_string_with_retry.
415+
backoff = 2**attempt * random.random()
416+
self._logger.info(
417+
'HttpError status_code=%s while refreshing access token: '
418+
'Retrying in %.1f seconds',
419+
e.status_code, backoff)
420+
time.sleep(backoff)
421+
else:
422+
raise
406423

407424
token_content = res.json()
408425
self._oauth2_access_token = token_content["access_token"]
@@ -619,14 +636,14 @@ def raise_dropbox_error_for_resp(self, res):
619636
raise InternalServerError(request_id, res.status_code, res.text)
620637
elif res.status_code == 400:
621638
try:
622-
if res.json()['error'] == 'invalid_grant':
639+
if res.json().get('error') == 'invalid_grant':
623640
request_id = res.headers.get('x-dropbox-request-id')
624641
err = stone_serializers.json_compat_obj_decode(
625642
AuthError_validator, 'invalid_access_token')
626643
raise AuthError(request_id, err)
627644
else:
628645
raise BadInputError(request_id, res.text)
629-
except ValueError:
646+
except (ValueError, AttributeError):
630647
raise BadInputError(request_id, res.text)
631648
elif res.status_code == 401:
632649
assert res.headers.get('content-type') == 'application/json', (

dropbox/oauth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ class DropboxOAuth2FlowBase(object):
116116
def __init__(self, consumer_key, consumer_secret=None, locale=None, token_access_type=None,
117117
scope=None, include_granted_scopes=None, use_pkce=False, timeout=DEFAULT_TIMEOUT,
118118
ca_certs=None):
119-
if scope is not None and (len(scope) == 0 or not isinstance(scope, list)):
119+
if scope is not None and (not isinstance(scope, list) or len(scope) == 0):
120120
raise BadInputException("Scope list must be of type list")
121121
if token_access_type is not None and token_access_type not in TOKEN_ACCESS_TYPES:
122122
raise BadInputException("Token access type must be from the following enum: {}".format(

test/unit/test_dropbox_unit.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# Tests OAuth Flow
99
from dropbox import DropboxOAuth2Flow, session, Dropbox, create_session
1010
from dropbox.dropbox_client import BadInputException, DropboxTeam
11-
from dropbox.exceptions import AuthError
11+
from dropbox.exceptions import AuthError, BadInputError
1212
from dropbox.oauth import OAuth2FlowNoRedirectResult, DropboxOAuth2FlowNoRedirect
1313
from datetime import datetime, timedelta
1414

@@ -255,6 +255,37 @@ def invalid_grant_session_instance(self, mocker):
255255
mocker.patch.object(session_obj, 'post', return_value=post_response)
256256
return session_obj
257257

258+
@pytest.fixture(scope='function')
259+
def bad_request_no_error_key_session_instance(self, mocker):
260+
# A 400 response whose JSON body does not contain an "error" key.
261+
session_obj = create_session()
262+
post_response = mock.MagicMock(status_code=400)
263+
post_response.json.return_value = {"error_description": "some other problem"}
264+
post_response.text = '{"error_description": "some other problem"}'
265+
mocker.patch.object(session_obj, 'post', return_value=post_response)
266+
return session_obj
267+
268+
@pytest.fixture(scope='function')
269+
def server_error_then_ok_session_instance(self, mocker):
270+
# First refresh POST returns 500, second returns 200 with a valid token.
271+
session_obj = create_session()
272+
error_response = mock.MagicMock(status_code=500)
273+
error_response.text = 'internal server error'
274+
ok_response = mock.MagicMock(status_code=200)
275+
ok_response.json.return_value = {"access_token": ACCESS_TOKEN, "expires_in": EXPIRES_IN}
276+
mocker.patch.object(session_obj, 'post',
277+
side_effect=[error_response, ok_response])
278+
return session_obj
279+
280+
@pytest.fixture(scope='function')
281+
def server_error_session_instance(self, mocker):
282+
# Every refresh POST returns 500.
283+
session_obj = create_session()
284+
error_response = mock.MagicMock(status_code=500)
285+
error_response.text = 'internal server error'
286+
mocker.patch.object(session_obj, 'post', return_value=error_response)
287+
return session_obj
288+
258289
def test_default_Dropbox_raises_assertion_error(self):
259290
with pytest.raises(BadInputException):
260291
# Requires either access token or refresh token
@@ -406,6 +437,55 @@ def test_team_client_as_user(self, session_instance):
406437
session=session_instance)
407438
dbx.as_user(TEAM_MEMBER_ID)
408439

440+
def test_non_list_scope_raises_bad_input(self, session_instance):
441+
# A non-list scope with no len() must raise BadInputException, not
442+
# TypeError from calling len() before the isinstance check.
443+
with pytest.raises(BadInputException):
444+
Dropbox(oauth2_access_token=ACCESS_TOKEN,
445+
scope=12345,
446+
session=session_instance)
447+
448+
def test_clone_does_not_double_user_agent(self, session_instance):
449+
dbx = Dropbox(oauth2_access_token=ACCESS_TOKEN, session=session_instance,
450+
user_agent='myapp')
451+
cloned = dbx.clone()
452+
# The base suffix must appear exactly once after cloning.
453+
assert cloned._user_agent.count('OfficialDropboxPythonSDKv2/') == 1
454+
assert cloned._user_agent == dbx._user_agent
455+
456+
def test_refresh_400_without_error_key(self, bad_request_no_error_key_session_instance):
457+
# A 400 body lacking the "error" key must raise BadInputError, not KeyError.
458+
dbx = Dropbox(oauth2_refresh_token=REFRESH_TOKEN,
459+
app_key=APP_KEY,
460+
app_secret=APP_SECRET,
461+
session=bad_request_no_error_key_session_instance)
462+
with pytest.raises(BadInputError):
463+
dbx.check_and_refresh_access_token()
464+
465+
def test_refresh_retries_on_server_error(self, server_error_then_ok_session_instance):
466+
# A 5xx on the refresh POST must be retried, then succeed.
467+
dbx = Dropbox(oauth2_refresh_token=REFRESH_TOKEN,
468+
app_key=APP_KEY,
469+
app_secret=APP_SECRET,
470+
max_retries_on_error=2,
471+
session=server_error_then_ok_session_instance)
472+
dbx.check_and_refresh_access_token()
473+
assert server_error_then_ok_session_instance.post.call_count == 2
474+
assert dbx._oauth2_access_token == ACCESS_TOKEN
475+
476+
def test_refresh_raises_after_exhausting_retries(self, server_error_session_instance):
477+
# Once retries are exhausted, the 5xx propagates as InternalServerError.
478+
from dropbox.exceptions import InternalServerError
479+
dbx = Dropbox(oauth2_refresh_token=REFRESH_TOKEN,
480+
app_key=APP_KEY,
481+
app_secret=APP_SECRET,
482+
max_retries_on_error=2,
483+
session=server_error_session_instance)
484+
with pytest.raises(InternalServerError):
485+
dbx.check_and_refresh_access_token()
486+
# Initial attempt + 2 retries.
487+
assert server_error_session_instance.post.call_count == 3
488+
409489

410490
class TestSession:
411491
def test_pickle_session(self):

0 commit comments

Comments
 (0)