diff --git a/dropbox/dropbox_client.py b/dropbox/dropbox_client.py index 358f062..b5f0efe 100644 --- a/dropbox/dropbox_client.py +++ b/dropbox/dropbox_client.py @@ -18,6 +18,7 @@ from stone.backends.python_rsrc import stone_serializers from dropbox import files +from dropbox.auth import AuthError as AuthErrorUnion from dropbox.auth import ( AuthError_validator, RateLimitError_validator, @@ -726,8 +727,19 @@ def raise_dropbox_error_for_resp(self, res): "Expected content-type to be application/json, got %r" % res.headers.get("content-type") ) - err = stone_serializers.json_compat_obj_decode(AuthError_validator, res.json()["error"]) - raise AuthError(request_id, err) + raw_error = res.json()["error"] + # Stone reserves the catch-all tag for unknown variants and rejects + # it on the wire, even with strict=False. Some 401 responses send + # "other" explicitly; keep those as authentication errors too. + if raw_error == "other" or ( + isinstance(raw_error, dict) and raw_error.get(".tag") == "other" + ): + err = AuthErrorUnion.other + else: + err = stone_serializers.json_compat_obj_decode( + AuthError_validator, raw_error, strict=False + ) + raise AuthError(request_id, err, body=res.text) elif res.status_code == HTTP_STATUS_INVALID_PATH_ROOT: err = stone_serializers.json_compat_obj_decode( PathRootError_validator, res.json()["error"] diff --git a/dropbox/exceptions.py b/dropbox/exceptions.py index 389c1cc..409294f 100644 --- a/dropbox/exceptions.py +++ b/dropbox/exceptions.py @@ -68,10 +68,13 @@ def __repr__(self): class AuthError(HttpError): - """Errors due to invalid authentication credentials.""" + """Errors due to invalid authentication credentials. - def __init__(self, request_id, error): - super(AuthError, self).__init__(request_id, 401, None) + The optional ``body`` contains the original HTTP response body, when available. + """ + + def __init__(self, request_id, error, body=None): + super(AuthError, self).__init__(request_id, 401, body) self.error = error def __repr__(self): diff --git a/test/unit/test_dropbox_unit.py b/test/unit/test_dropbox_unit.py index 1f49420..2a3c54c 100644 --- a/test/unit/test_dropbox_unit.py +++ b/test/unit/test_dropbox_unit.py @@ -788,6 +788,51 @@ def test_refresh_raises_after_exhausting_retries(self, server_error_session_inst # Initial attempt + 2 retries. assert server_error_session_instance.post.call_count == 3 + @pytest.mark.parametrize( + "raw_error, expected_tag", + [ + ("other", "other"), + ({".tag": "other"}, "other"), + ("future_auth_error", "other"), + ({".tag": "future_auth_error", "details": "new server error"}, "other"), + ("invalid_access_token", "invalid_access_token"), + ({".tag": "invalid_access_token", "future_field": True}, "invalid_access_token"), + ( + { + ".tag": "missing_scope", + "required_scope": "files.content.write", + "future_field": True, + }, + "missing_scope", + ), + ], + ) + def test_upload_preserves_auth_errors(self, session_instance, mocker, raw_error, expected_tag): + response = requests.Response() + response.status_code = 401 + response.headers = { + "content-type": "application/json", + "x-dropbox-request-id": "auth-request-id", + } + response._content = json.dumps( + {"error": raw_error, "error_summary": "authentication failed"} + ).encode("utf-8") + session_instance.post.return_value = response + dbx = Dropbox(ACCESS_TOKEN, session=session_instance) + refresh = mocker.patch.object(dbx, "refresh_access_token") + + with pytest.raises(AuthError) as caught: + dbx.files_upload(b"contents", "/test.txt") + + assert getattr(caught.value.error, "is_" + expected_tag)() + if expected_tag == "missing_scope": + assert caught.value.error.get_missing_scope().required_scope == "files.content.write" + assert caught.value.status_code == 401 + assert caught.value.request_id == "auth-request-id" + assert caught.value.body == response.text + session_instance.post.assert_called_once() + refresh.assert_not_called() + def test_refresh_on_expired_access_token_uses_client_scope(self, session_instance, mocker): # A 401 expired_access_token triggers a refresh, which must request the # scope the client was configured with, like check_and_refresh_access_token.