From 100dd2fab707e28d33183633d6bcae8f8519d0b1 Mon Sep 17 00:00:00 2001 From: Carl Lundin Date: Fri, 19 Aug 2022 22:32:42 +0000 Subject: [PATCH] feat: Retry behavior * Introduce `retryable` property to auth library exceptions. This can be used to determine if an exception should be retried. * Introduce `should_retry` parameter to token endpoints. If set to `False` the auth library will not retry failed requests. If set to `True` the auth library will retry failed requests. The default value is `True` to maintain existing behavior. * Expanded list of HTTP Status codes that will be retried. * Modified retry behavior to use exponential backoff. * Increased default retry attempts from 2 to 3. --- google/auth/_exponential_backoff.py | 107 ++++++++++++++ google/auth/exceptions.py | 17 ++- google/auth/transport/__init__.py | 11 +- google/oauth2/_client.py | 169 +++++++++++++++++------ google/oauth2/_client_async.py | 102 +++++++++----- google/oauth2/_reauth_async.py | 5 +- google/oauth2/reauth.py | 12 +- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes tests/compute_engine/test_credentials.py | 2 +- tests/oauth2/test__client.py | 154 +++++++++++++++++++-- tests/oauth2/test_reauth.py | 11 +- tests/test__exponential_backoff.py | 43 ++++++ tests/test_exceptions.py | 55 ++++++++ tests_async/oauth2/test__client_async.py | 120 +++++++++++++++- tests_async/oauth2/test_reauth_async.py | 16 ++- 15 files changed, 717 insertions(+), 107 deletions(-) create mode 100644 google/auth/_exponential_backoff.py create mode 100644 tests/test__exponential_backoff.py create mode 100644 tests/test_exceptions.py diff --git a/google/auth/_exponential_backoff.py b/google/auth/_exponential_backoff.py new file mode 100644 index 000000000..44e39d5a7 --- /dev/null +++ b/google/auth/_exponential_backoff.py @@ -0,0 +1,107 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time +import random + +# The default amount of retry total_attempts +_DEFAULT_RETRY_TOTAL_ATTEMPTS = 3 + +# The default initial backoff period (1.0 second). +_DEFAULT_INITIAL_INTERVAL_MILLIS = 1000 + +# The default randomization factor (1.1 which results in a random period ranging +# between 10% below and 10% above the retry interval). +_DEFAULT_RANDOMIZATION_FACTOR = 0.1 + +# The default multiplier value (2 which is 100% increase per back off). +_DEFAULT_MULTIPLIER = 2.0 + +"""Exponential Backoff Utility + +This is a private module that implements the exponential back off algorithm. +It can be used as a utility for code that needs to retry on failure, for example +an HTTP request. +""" + + +class ExponentialBackoff(object): + """An exponential backoff iterator. This can be used in a for loop to + perform requests with exponential backoff. + + Args: + total_attempts Optional[int]: + The maximum amount of retries that should happen. + The default value is 3 attempts. + initial_wait_millis Optional[int]: + The amount of time to sleep in the first backoff. This parameter + should be in milliseconds. + The default value is 1 second. + randomization_factor Optional[float]: + The amount of jitter that should be in each backoff. For example, + a value of 0.1 will introduce a jitter range of 10% to the + current backoff period. + The default value is 0.1. + multiplier Optional[float]: + The backoff multipler. This adjusts how much each backoff will + increase. For example a value of 2.0 leads to a 200% backoff + on each attempt. If the initial_wait is 1.0 it would look like + this sequence [1.0, 2.0, 4.0, 8.0]. + The default value is 2.0. + """ + + def __init__( + self, + total_attempts=_DEFAULT_RETRY_TOTAL_ATTEMPTS, + initial_wait_millis=_DEFAULT_INITIAL_INTERVAL_MILLIS, + randomization_factor=_DEFAULT_RANDOMIZATION_FACTOR, + multiplier=_DEFAULT_MULTIPLIER, + ): + self._total_attempts = total_attempts + self._initial_wait_millis = initial_wait_millis + + # convert milliseconds to seconds for the time.sleep API + self._current_wait_in_seconds = self._initial_wait_millis * 0.001 + + self._randomization_factor = randomization_factor + self._multiplier = multiplier + self._backoff_count = 0 + + def __iter__(self): + self._backoff_count = 0 + self._current_wait_in_seconds = self._initial_wait_millis * 0.001 + return self + + def __next__(self): + if self._backoff_count >= self._total_attempts: + raise StopIteration + self._backoff_count += 1 + + jitter_range = self._current_wait_in_seconds * self._randomization_factor + jitter = random.uniform(0, jitter_range) + + time.sleep(self._current_wait_in_seconds + jitter) + + self._current_wait_in_seconds *= self._multiplier + return self._backoff_count + + @property + def total_attempts(self): + """The total amount of backoff attempts that will be made.""" + return self._total_attempts + + @property + def backoff_count(self): + """The current amount of backoff attempts that have been made.""" + return self._backoff_count diff --git a/google/auth/exceptions.py b/google/auth/exceptions.py index e9e737780..7760c87b8 100644 --- a/google/auth/exceptions.py +++ b/google/auth/exceptions.py @@ -18,6 +18,15 @@ class GoogleAuthError(Exception): """Base class for all google.auth errors.""" + def __init__(self, *args, **kwargs): + super(GoogleAuthError, self).__init__(*args) + retryable = kwargs.get("retryable", False) + self._retryable = retryable + + @property + def retryable(self): + return self._retryable + class TransportError(GoogleAuthError): """Used to indicate an error occurred during an HTTP request.""" @@ -44,6 +53,10 @@ class MutualTLSChannelError(GoogleAuthError): class ClientCertError(GoogleAuthError): """Used to indicate that client certificate is missing or invalid.""" + @property + def retryable(self): + return False + class OAuthError(GoogleAuthError): """Used to indicate an error occurred during an OAuth related HTTP @@ -53,9 +66,9 @@ class OAuthError(GoogleAuthError): class ReauthFailError(RefreshError): """An exception for when reauth failed.""" - def __init__(self, message=None): + def __init__(self, message=None, **kwargs): super(ReauthFailError, self).__init__( - "Reauthentication failed. {0}".format(message) + "Reauthentication failed. {0}".format(message), **kwargs ) diff --git a/google/auth/transport/__init__.py b/google/auth/transport/__init__.py index 374e7b4d7..8b5048e31 100644 --- a/google/auth/transport/__init__.py +++ b/google/auth/transport/__init__.py @@ -29,9 +29,18 @@ import six from six.moves import http_client +DEFAULT_RETRYABLE_STATUS_CODES = ( + http_client.INTERNAL_SERVER_ERROR, + http_client.SERVICE_UNAVAILABLE, + http_client.REQUEST_TIMEOUT, +) +"""Sequence[int]: HTTP status codes indicating a request can be retried. +""" + + DEFAULT_REFRESH_STATUS_CODES = (http_client.UNAUTHORIZED,) """Sequence[int]: Which HTTP status code indicate that credentials should be -refreshed and a request should be retried. +refreshed. """ DEFAULT_MAX_REFRESH_ATTEMPTS = 2 diff --git a/google/oauth2/_client.py b/google/oauth2/_client.py index 847c5db8a..737a2a994 100644 --- a/google/oauth2/_client.py +++ b/google/oauth2/_client.py @@ -33,6 +33,8 @@ from google.auth import _helpers from google.auth import exceptions from google.auth import jwt +from google.auth import transport +from google.auth import _exponential_backoff _URLENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded" _JSON_CONTENT_TYPE = "application/json" @@ -40,17 +42,22 @@ _REFRESH_GRANT_TYPE = "refresh_token" -def _handle_error_response(response_data): +def _handle_error_response(response_data, retryable_error): """Translates an error response into an exception. Args: response_data (Mapping | str): The decoded response data. + retryable_error Optional[bool]: A boolean indicating if an error is retry able. + Defaults to False. Raises: google.auth.exceptions.RefreshError: The errors contained in response_data. """ + + retryable_error = retryable_error if retryable_error else False + if isinstance(response_data, six.string_types): - raise exceptions.RefreshError(response_data) + raise exceptions.RefreshError(response_data, retryable=retryable_error) try: error_details = "{}: {}".format( response_data["error"], response_data.get("error_description") @@ -59,7 +66,42 @@ def _handle_error_response(response_data): except (KeyError, ValueError): error_details = json.dumps(response_data) - raise exceptions.RefreshError(error_details, response_data) + raise exceptions.RefreshError( + error_details, response_data, retryable=retryable_error + ) + + +def _can_retry(status_code, response_body): + """Checks if a request can be retried by inspecting the status code + and response body of the request. + + Args: + status_code (int): The response status code. + response_data (Mapping | str): The decoded response data. + + Returns: + bool: True if the response is retryable. False otherwise. + """ + try: + response_data = json.loads(response_body) + error_desc = response_data.get("error_description") or "" + error_code = response_data.get("error") or "" + + # Per Oauth 2.0 RFC https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1 + # This is needed because a redirect will not return a 500 status code. + retryable_error_descriptions = {"internal_failure", "server_error"} + + if any(e in retryable_error_descriptions for e in (error_code, error_desc)): + return True + + except ValueError: + # Ignore value error exceptions + pass + + return ( + status_code in transport.DEFAULT_REFRESH_STATUS_CODES + or status_code in transport.DEFAULT_RETRYABLE_STATUS_CODES + ) def _parse_expiry(response_data): @@ -81,7 +123,13 @@ def _parse_expiry(response_data): def _token_endpoint_request_no_throw( - request, token_uri, body, access_token=None, use_json=False, **kwargs + request, + token_uri, + body, + access_token=None, + use_json=False, + should_retry=True, + **kwargs ): """Makes a request to the OAuth 2.0 authorization server's token endpoint. This function doesn't throw on response errors. @@ -95,6 +143,7 @@ def _token_endpoint_request_no_throw( access_token (Optional(str)): The access token needed to make the request. use_json (Optional(bool)): Use urlencoded format or json format for the content type. The default value is False. + should_retry (bool): Enable or disable request retry behavior. kwargs: Additional arguments passed on to the request method. The kwargs will be passed to `requests.request` method, see: https://docs.python-requests.org/en/latest/api/#requests.request. @@ -104,8 +153,10 @@ def _token_endpoint_request_no_throw( side SSL certificate verification. Returns: - Tuple(bool, Mapping[str, str]): A boolean indicating if the request is - successful, and a mapping for the JSON-decoded response data. + Tuple(bool, Mapping[str, str], Optional[bool]): A boolean indicating + if the request is successful, a mapping for the JSON-decoded response + data and in the case of an error a boolean indicating if the error + is retryable. """ if use_json: headers = {"Content-Type": _JSON_CONTENT_TYPE} @@ -117,10 +168,7 @@ def _token_endpoint_request_no_throw( if access_token: headers["Authorization"] = "Bearer {}".format(access_token) - retry = 0 - # retry to fetch token for maximum of two times if any internal failure - # occurs. - while True: + def _perform_request(): response = request( method="POST", url=token_uri, headers=headers, body=body, **kwargs ) @@ -129,32 +177,51 @@ def _token_endpoint_request_no_throw( if hasattr(response.data, "decode") else response.data ) + return response, response_body + + response, response_body = _perform_request() + + if response.status == http_client.OK: + # response_body should be a JSON + response_data = json.loads(response_body) + return True, response_data, None + + retryable_error = _can_retry( + status_code=response.status, response_body=response_body + ) + + if not retryable_error or not should_retry: + # For a failed response, response_body could be a string + return False, response_body, retryable_error + + retries = _exponential_backoff.ExponentialBackoff() + for _ in retries: + response, response_body = _perform_request() if response.status == http_client.OK: # response_body should be a JSON response_data = json.loads(response_body) - break - else: + return True, response_data, None + + retryable_error = _can_retry( + status_code=response.status, response_body=response_body + ) + + if not retryable_error: # For a failed response, response_body could be a string - try: - response_data = json.loads(response_body) - error_desc = response_data.get("error_description") or "" - error_code = response_data.get("error") or "" - if ( - any(e == "internal_failure" for e in (error_code, error_desc)) - and retry < 1 - ): - retry += 1 - continue - except ValueError: - response_data = response_body - return False, response_data - - return True, response_data + return False, response_body, retryable_error + + return False, response_body, retryable_error def _token_endpoint_request( - request, token_uri, body, access_token=None, use_json=False, **kwargs + request, + token_uri, + body, + access_token=None, + use_json=False, + should_retry=True, + **kwargs ): """Makes a request to the OAuth 2.0 authorization server's token endpoint. @@ -167,6 +234,7 @@ def _token_endpoint_request( access_token (Optional(str)): The access token needed to make the request. use_json (Optional(bool)): Use urlencoded format or json format for the content type. The default value is False. + should_retry (bool): Enable or disable request retry behavior. kwargs: Additional arguments passed on to the request method. The kwargs will be passed to `requests.request` method, see: https://docs.python-requests.org/en/latest/api/#requests.request. @@ -182,15 +250,22 @@ def _token_endpoint_request( google.auth.exceptions.RefreshError: If the token endpoint returned an error. """ - response_status_ok, response_data = _token_endpoint_request_no_throw( - request, token_uri, body, access_token=access_token, use_json=use_json, **kwargs + + response_status_ok, response_data, retryable_error = _token_endpoint_request_no_throw( + request, + token_uri, + body, + access_token=access_token, + use_json=use_json, + should_retry=should_retry, + **kwargs ) if not response_status_ok: - _handle_error_response(response_data) + _handle_error_response(response_data, retryable_error) return response_data -def jwt_grant(request, token_uri, assertion): +def jwt_grant(request, token_uri, assertion, should_retry=True): """Implements the JWT Profile for OAuth 2.0 Authorization Grants. For more details, see `rfc7523 section 4`_. @@ -201,6 +276,7 @@ def jwt_grant(request, token_uri, assertion): token_uri (str): The OAuth 2.0 authorizations server's token endpoint URI. assertion (str): The OAuth 2.0 assertion. + should_retry (bool): Enable or disable request retry behavior. Returns: Tuple[str, Optional[datetime], Mapping[str, str]]: The access token, @@ -214,12 +290,16 @@ def jwt_grant(request, token_uri, assertion): """ body = {"assertion": assertion, "grant_type": _JWT_GRANT_TYPE} - response_data = _token_endpoint_request(request, token_uri, body) + response_data = _token_endpoint_request( + request, token_uri, body, should_retry=should_retry + ) try: access_token = response_data["access_token"] except KeyError as caught_exc: - new_exc = exceptions.RefreshError("No access token in response.", response_data) + new_exc = exceptions.RefreshError( + "No access token in response.", response_data, retryable=True + ) six.raise_from(new_exc, caught_exc) expiry = _parse_expiry(response_data) @@ -227,7 +307,7 @@ def jwt_grant(request, token_uri, assertion): return access_token, expiry, response_data -def id_token_jwt_grant(request, token_uri, assertion): +def id_token_jwt_grant(request, token_uri, assertion, should_retry=True): """Implements the JWT Profile for OAuth 2.0 Authorization Grants, but requests an OpenID Connect ID Token instead of an access token. @@ -242,6 +322,7 @@ def id_token_jwt_grant(request, token_uri, assertion): URI. assertion (str): JWT token signed by a service account. The token's payload must include a ``target_audience`` claim. + should_retry (bool): Enable or disable request retry behavior. Returns: Tuple[str, Optional[datetime], Mapping[str, str]]: @@ -254,12 +335,16 @@ def id_token_jwt_grant(request, token_uri, assertion): """ body = {"assertion": assertion, "grant_type": _JWT_GRANT_TYPE} - response_data = _token_endpoint_request(request, token_uri, body) + response_data = _token_endpoint_request( + request, token_uri, body, should_retry=should_retry + ) try: id_token = response_data["id_token"] except KeyError as caught_exc: - new_exc = exceptions.RefreshError("No ID token in response.", response_data) + new_exc = exceptions.RefreshError( + "No ID token in response.", response_data, retryable=True + ) six.raise_from(new_exc, caught_exc) payload = jwt.decode(id_token, verify=False) @@ -288,7 +373,9 @@ def _handle_refresh_grant_response(response_data, refresh_token): try: access_token = response_data["access_token"] except KeyError as caught_exc: - new_exc = exceptions.RefreshError("No access token in response.", response_data) + new_exc = exceptions.RefreshError( + "No access token in response.", response_data, retryable=True + ) six.raise_from(new_exc, caught_exc) refresh_token = response_data.get("refresh_token", refresh_token) @@ -305,6 +392,7 @@ def refresh_grant( client_secret, scopes=None, rapt_token=None, + should_retry=True, ): """Implements the OAuth 2.0 refresh token grant. @@ -324,6 +412,7 @@ def refresh_grant( token has a wild card scope (e.g. 'https://www.googleapis.com/auth/any-api'). rapt_token (Optional(str)): The reauth Proof Token. + should_retry (bool): Enable or disable request retry behavior. Returns: Tuple[str, str, Optional[datetime], Mapping[str, str]]: The access @@ -347,5 +436,7 @@ def refresh_grant( if rapt_token: body["rapt"] = rapt_token - response_data = _token_endpoint_request(request, token_uri, body) + response_data = _token_endpoint_request( + request, token_uri, body, should_retry=should_retry + ) return _handle_refresh_grant_response(response_data, refresh_token) diff --git a/google/oauth2/_client_async.py b/google/oauth2/_client_async.py index cf5121137..7413a132b 100644 --- a/google/oauth2/_client_async.py +++ b/google/oauth2/_client_async.py @@ -32,11 +32,13 @@ from google.auth import exceptions from google.auth import jwt +from google.auth import transport +from google.auth import _exponential_backoff from google.oauth2 import _client as client async def _token_endpoint_request_no_throw( - request, token_uri, body, access_token=None, use_json=False + request, token_uri, body, access_token=None, use_json=False, should_retry=True ): """Makes a request to the OAuth 2.0 authorization server's token endpoint. This function doesn't throw on response errors. @@ -50,10 +52,13 @@ async def _token_endpoint_request_no_throw( access_token (Optional(str)): The access token needed to make the request. use_json (Optional(bool)): Use urlencoded format or json format for the content type. The default value is False. + should_retry (bool): Enable or disable request retry behavior. Returns: - Tuple(bool, Mapping[str, str]): A boolean indicating if the request is - successful, and a mapping for the JSON-decoded response data. + Tuple(bool, Mapping[str, str], Optional[bool]): A boolean indicating + if the request is successful, a mapping for the JSON-decoded response + data and in the case of an error a boolean indicating if the error + is retryable. """ if use_json: headers = {"Content-Type": client._JSON_CONTENT_TYPE} @@ -65,11 +70,7 @@ async def _token_endpoint_request_no_throw( if access_token: headers["Authorization"] = "Bearer {}".format(access_token) - retry = 0 - # retry to fetch token for maximum of two times if any internal failure - # occurs. - while True: - + async def _perform_request(): response = await request( method="POST", url=token_uri, headers=headers, body=body ) @@ -82,27 +83,45 @@ async def _token_endpoint_request_no_throw( if hasattr(response_body1, "decode") else response_body1 ) + return response, response_body + + response, response_body = await _perform_request() + if response.status == http_client.OK: + # response_body should be a JSON response_data = json.loads(response_body) + return True, response_data, None + + retryable_error = client._can_retry( + status_code=response.status, response_body=response_body + ) + + if not retryable_error or not should_retry: + # For a failed response, response_body could be a string + return False, response_body, retryable_error + + retries = _exponential_backoff.ExponentialBackoff() + for _ in retries: + response, response_body = await _perform_request() if response.status == http_client.OK: - break - else: - error_desc = response_data.get("error_description") or "" - error_code = response_data.get("error") or "" - if ( - any(e == "internal_failure" for e in (error_code, error_desc)) - and retry < 1 - ): - retry += 1 - continue - return response.status == http_client.OK, response_data + # response_body should be a JSON + response_data = json.loads(response_body) + return True, response_data, None + + retryable_error = client._can_retry( + status_code=response.status, response_body=response_body + ) - return response.status == http_client.OK, response_data + if not retryable_error: + # For a failed response, response_body could be a string + return False, response_body, retryable_error + + return False, response_body, retryable_error async def _token_endpoint_request( - request, token_uri, body, access_token=None, use_json=False + request, token_uri, body, access_token=None, use_json=False, should_retry=True ): """Makes a request to the OAuth 2.0 authorization server's token endpoint. @@ -115,6 +134,7 @@ async def _token_endpoint_request( access_token (Optional(str)): The access token needed to make the request. use_json (Optional(bool)): Use urlencoded format or json format for the content type. The default value is False. + should_retry (bool): Enable or disable request retry behavior. Returns: Mapping[str, str]: The JSON-decoded response data. @@ -123,15 +143,21 @@ async def _token_endpoint_request( google.auth.exceptions.RefreshError: If the token endpoint returned an error. """ - response_status_ok, response_data = await _token_endpoint_request_no_throw( - request, token_uri, body, access_token=access_token, use_json=use_json + + response_status_ok, response_data, retryable_error = await _token_endpoint_request_no_throw( + request, + token_uri, + body, + access_token=access_token, + use_json=use_json, + should_retry=should_retry, ) if not response_status_ok: - client._handle_error_response(response_data) + client._handle_error_response(response_data, retryable_error) return response_data -async def jwt_grant(request, token_uri, assertion): +async def jwt_grant(request, token_uri, assertion, should_retry=True): """Implements the JWT Profile for OAuth 2.0 Authorization Grants. For more details, see `rfc7523 section 4`_. @@ -142,6 +168,7 @@ async def jwt_grant(request, token_uri, assertion): token_uri (str): The OAuth 2.0 authorizations server's token endpoint URI. assertion (str): The OAuth 2.0 assertion. + should_retry (bool): Enable or disable request retry behavior. Returns: Tuple[str, Optional[datetime], Mapping[str, str]]: The access token, @@ -155,12 +182,16 @@ async def jwt_grant(request, token_uri, assertion): """ body = {"assertion": assertion, "grant_type": client._JWT_GRANT_TYPE} - response_data = await _token_endpoint_request(request, token_uri, body) + response_data = await _token_endpoint_request( + request, token_uri, body, should_retry=should_retry + ) try: access_token = response_data["access_token"] except KeyError as caught_exc: - new_exc = exceptions.RefreshError("No access token in response.", response_data) + new_exc = exceptions.RefreshError( + "No access token in response.", response_data, retryable=True + ) six.raise_from(new_exc, caught_exc) expiry = client._parse_expiry(response_data) @@ -168,7 +199,7 @@ async def jwt_grant(request, token_uri, assertion): return access_token, expiry, response_data -async def id_token_jwt_grant(request, token_uri, assertion): +async def id_token_jwt_grant(request, token_uri, assertion, should_retry=True): """Implements the JWT Profile for OAuth 2.0 Authorization Grants, but requests an OpenID Connect ID Token instead of an access token. @@ -183,6 +214,7 @@ async def id_token_jwt_grant(request, token_uri, assertion): URI. assertion (str): JWT token signed by a service account. The token's payload must include a ``target_audience`` claim. + should_retry (bool): Enable or disable request retry behavior. Returns: Tuple[str, Optional[datetime], Mapping[str, str]]: @@ -195,12 +227,16 @@ async def id_token_jwt_grant(request, token_uri, assertion): """ body = {"assertion": assertion, "grant_type": client._JWT_GRANT_TYPE} - response_data = await _token_endpoint_request(request, token_uri, body) + response_data = await _token_endpoint_request( + request, token_uri, body, should_retry=should_retry + ) try: id_token = response_data["id_token"] except KeyError as caught_exc: - new_exc = exceptions.RefreshError("No ID token in response.", response_data) + new_exc = exceptions.RefreshError( + "No ID token in response.", response_data, retryable=True + ) six.raise_from(new_exc, caught_exc) payload = jwt.decode(id_token, verify=False) @@ -217,6 +253,7 @@ async def refresh_grant( client_secret, scopes=None, rapt_token=None, + should_retry=True, ): """Implements the OAuth 2.0 refresh token grant. @@ -236,6 +273,7 @@ async def refresh_grant( token has a wild card scope (e.g. 'https://www.googleapis.com/auth/any-api'). rapt_token (Optional(str)): The reauth Proof Token. + should_retry (bool): Enable or disable request retry behavior. Returns: Tuple[str, Optional[str], Optional[datetime], Mapping[str, str]]: The @@ -259,5 +297,7 @@ async def refresh_grant( if rapt_token: body["rapt"] = rapt_token - response_data = await _token_endpoint_request(request, token_uri, body) + response_data = await _token_endpoint_request( + request, token_uri, body, should_retry=should_retry + ) return client._handle_refresh_grant_response(response_data, refresh_token) diff --git a/google/oauth2/_reauth_async.py b/google/oauth2/_reauth_async.py index 0276ddd0b..c9770f867 100644 --- a/google/oauth2/_reauth_async.py +++ b/google/oauth2/_reauth_async.py @@ -292,7 +292,7 @@ async def refresh_grant( if rapt_token: body["rapt"] = rapt_token - response_status_ok, response_data = await _client_async._token_endpoint_request_no_throw( + response_status_ok, response_data, retryable_error = await _client_async._token_endpoint_request_no_throw( request, token_uri, body ) if ( @@ -317,12 +317,13 @@ async def refresh_grant( ( response_status_ok, response_data, + retryable_error, ) = await _client_async._token_endpoint_request_no_throw( request, token_uri, body ) if not response_status_ok: - _client._handle_error_response(response_data) + _client._handle_error_response(response_data, retryable_error) refresh_response = _client._handle_refresh_grant_response( response_data, refresh_token ) diff --git a/google/oauth2/reauth.py b/google/oauth2/reauth.py index cbf1d7f09..afd491fd4 100644 --- a/google/oauth2/reauth.py +++ b/google/oauth2/reauth.py @@ -319,7 +319,7 @@ def refresh_grant( if rapt_token: body["rapt"] = rapt_token - response_status_ok, response_data = _client._token_endpoint_request_no_throw( + response_status_ok, response_data, retryable_error = _client._token_endpoint_request_no_throw( request, token_uri, body ) if ( @@ -339,12 +339,14 @@ def refresh_grant( request, client_id, client_secret, refresh_token, token_uri, scopes=scopes ) body["rapt"] = rapt_token - (response_status_ok, response_data) = _client._token_endpoint_request_no_throw( - request, token_uri, body - ) + ( + response_status_ok, + response_data, + retryable_error, + ) = _client._token_endpoint_request_no_throw(request, token_uri, body) if not response_status_ok: - _client._handle_error_response(response_data) + _client._handle_error_response(response_data, retryable_error) return _client._handle_refresh_grant_response(response_data, refresh_token) + ( rapt_token, ) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index e8785c796c86ffe2b670c0133f576834ab2ec311..97c84ff1a2867b1d30db83506f8a52af77c4bbed 100644 GIT binary patch literal 10324 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

DX#d9^0S?RvIQ3F-dRCLe|~ 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 mY7U<*amU5