Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions googleapiclient/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,10 @@ def _should_retry_response(resp_status, content):
if resp_status == _TOO_MANY_REQUESTS:
return True

# For 403 errors, we have to check for the `reason` in the response to
# For 403 and 400 errors, we have to check for the `reason` in the response to
# determine if we should retry.
if resp_status == http_client.FORBIDDEN:
# If there's no details about the 403 type, don't retry.
if resp_status in [http_client.FORBIDDEN, http_client.BAD_REQUEST]:
# If there's no details about the error, don't retry.
if not content:
return False

Expand Down Expand Up @@ -137,11 +137,16 @@ def _should_retry_response(resp_status, content):
LOGGER.warning("Invalid JSON content from response: %s", content)
return False

LOGGER.warning('Encountered 403 Forbidden with reason "%s"', reason)

# Only retry on rate limit related failures.
if reason in ("userRateLimitExceeded", "rateLimitExceeded"):
return True
if resp_status == http_client.FORBIDDEN:
LOGGER.warning('Encountered 403 Forbidden with reason "%s"', reason)
# Only retry on rate limit related failures.
if reason in ("userRateLimitExceeded", "rateLimitExceeded"):
return True
elif resp_status == http_client.BAD_REQUEST:
LOGGER.warning('Encountered 400 Bad Request with reason "%s"', reason)
# Only retry on precondition failures.
if reason in ("failedPrecondition", "preconditionFailed"):
return True
Comment on lines +140 to +149
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better maintainability and to make it easier to add more retryable status codes in the future, consider refactoring this if/elif block to be more data-driven. You can use a dictionary to map status codes to their retryable reasons and log messages.

Suggested change
if resp_status == http_client.FORBIDDEN:
LOGGER.warning('Encountered 403 Forbidden with reason "%s"', reason)
# Only retry on rate limit related failures.
if reason in ("userRateLimitExceeded", "rateLimitExceeded"):
return True
elif resp_status == http_client.BAD_REQUEST:
LOGGER.warning('Encountered 400 Bad Request with reason "%s"', reason)
# Only retry on precondition failures.
if reason in ("failedPrecondition", "preconditionFailed"):
return True
RETRYABLE_INFO = {
http_client.FORBIDDEN: {
"reasons": ("userRateLimitExceeded", "rateLimitExceeded"),
"message": 'Encountered 403 Forbidden with reason "%s"',
},
http_client.BAD_REQUEST: {
"reasons": ("failedPrecondition", "preconditionFailed"),
"message": 'Encountered 400 Bad Request with reason "%s"',
},
}
if resp_status in RETRYABLE_INFO:
info = RETRYABLE_INFO[resp_status]
LOGGER.warning(info["message"], reason)
if reason in info["reasons"]:
return True


# Everything else is a success or non-retriable so break.
return False
Expand Down
42 changes: 42 additions & 0 deletions tests/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,20 @@ def test_media_io_base_download_unknown_media_size(self):
}
}"""

FAILED_PRECONDITION_RESPONSE = """{
"error": {
"errors": [
{
"domain": "global",
"reason": "failedPrecondition",
"message": "Precondition Failed"
}
],
"code": 400,
"message": "Precondition Failed"
}
}"""


NOT_CONFIGURED_RESPONSE = """{
"error": {
Expand Down Expand Up @@ -1056,6 +1070,34 @@ def test_no_retry_succeeds(self):

self.assertEqual(0, len(sleeptimes))

def test_retry_400_failed_precondition(self):
num_retries = 2
resp_seq = [
({"status": "400"}, FAILED_PRECONDITION_RESPONSE),
({"status": "200"}, "{}")
]
http = HttpMockSequence(resp_seq)
model = JsonModel()
uri = "https://www.googleapis.com/someapi/v1/collection/?foo=bar"
method = "POST"
request = HttpRequest(
http,
model.response,
uri,
method=method,
body="{}",
headers={"content-type": "application/json"},
)

sleeptimes = []
request._sleep = lambda x: sleeptimes.append(x)
request._rand = lambda: 10

request.execute(num_retries=num_retries)

self.assertEqual(1, len(sleeptimes))
self.assertEqual(10 * 2 ** 1, sleeptimes[0])
Comment on lines +1073 to +1099
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The new test test_retry_400_failed_precondition is great for covering the failedPrecondition reason. However, the implementation in http.py also handles the preconditionFailed reason. It would be beneficial to add a test case for preconditionFailed as well to ensure full coverage of the new logic.

You could achieve this by parameterizing the test or by adding a separate test method for the preconditionFailed case.


def test_no_retry_fails_fast(self):
http = HttpMockSequence([({"status": "500"}, ""), ({"status": "200"}, "{}")])
model = JsonModel()
Expand Down
Loading