Skip to content

Commit c6425a0

Browse files
eesheeshJon Wayne Parrott
authored andcommitted
Add retry on rate limiting API responses and network timeouts
1 parent f99fa88 commit c6425a0

File tree

3 files changed

+275
-22
lines changed

3 files changed

+275
-22
lines changed

googleapiclient/http.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"""
2121
from __future__ import absolute_import
2222
import six
23+
from six.moves import http_client
2324
from six.moves import range
2425

2526
__author__ = '[email protected] (Joe Gregorio)'
@@ -36,6 +37,7 @@
3637
import mimetypes
3738
import os
3839
import random
40+
import socket
3941
import ssl
4042
import sys
4143
import time
@@ -63,6 +65,51 @@
6365

6466
MAX_URI_LENGTH = 2048
6567

68+
_TOO_MANY_REQUESTS = 429
69+
70+
71+
def _should_retry_response(resp_status, content):
72+
"""Determines whether a response should be retried.
73+
74+
Args:
75+
resp_status: The response status received.
76+
content: The response content body.
77+
78+
Returns:
79+
True if the response should be retried, otherwise False.
80+
"""
81+
# Retry on 5xx errors.
82+
if resp_status >= 500:
83+
return True
84+
85+
# Retry on 429 errors.
86+
if resp_status == _TOO_MANY_REQUESTS:
87+
return True
88+
89+
# For 403 errors, we have to check for the `reason` in the response to
90+
# determine if we should retry.
91+
if resp_status == six.moves.http_client.FORBIDDEN:
92+
# If there's no details about the 403 type, don't retry.
93+
if not content:
94+
return False
95+
96+
# Content is in JSON format.
97+
try:
98+
data = json.loads(content.decode('utf-8'))
99+
reason = data['error']['errors'][0]['reason']
100+
except (UnicodeDecodeError, ValueError, KeyError):
101+
LOGGER.warning('Invalid JSON content from response: %s', content)
102+
return False
103+
104+
LOGGER.warning('Encountered 403 Forbidden with reason "%s"', reason)
105+
106+
# Only retry on rate limit related failures.
107+
if reason in ('userRateLimitExceeded', 'rateLimitExceeded', ):
108+
return True
109+
110+
# Everything else is a success or non-retriable so break.
111+
return False
112+
66113

67114
def _retry_request(http, num_retries, req_type, sleep, rand, uri, method, *args,
68115
**kwargs):
@@ -84,21 +131,37 @@ def _retry_request(http, num_retries, req_type, sleep, rand, uri, method, *args,
84131
resp, content - Response from the http request (may be HTTP 5xx).
85132
"""
86133
resp = None
134+
content = None
87135
for retry_num in range(num_retries + 1):
88136
if retry_num > 0:
89-
sleep(rand() * 2**retry_num)
137+
# Sleep before retrying.
138+
sleep_time = rand() * 2 ** retry_num
90139
LOGGER.warning(
91-
'Retry #%d for %s: %s %s%s' % (retry_num, req_type, method, uri,
92-
', following status: %d' % resp.status if resp else ''))
140+
'Sleeping %.2f seconds before retry %d of %d for %s: %s %s, after %s',
141+
sleep_time, retry_num, num_retries, req_type, method, uri,
142+
resp.status if resp else exception)
143+
sleep(sleep_time)
93144

94145
try:
146+
exception = None
95147
resp, content = http.request(uri, method, *args, **kwargs)
96-
except ssl.SSLError:
97-
if retry_num == num_retries:
148+
# Retry on SSL errors and socket timeout errors.
149+
except ssl.SSLError as ssl_error:
150+
exception = ssl_error
151+
except socket.error as socket_error:
152+
# errno's contents differ by platform, so we have to match by name.
153+
if socket.errno.errorcode.get(socket_error.errno) not in (
154+
'WSAETIMEDOUT', 'ETIMEDOUT', ):
98155
raise
156+
exception = socket_error
157+
158+
if exception:
159+
if retry_num == num_retries:
160+
raise exception
99161
else:
100162
continue
101-
if resp.status < 500:
163+
164+
if not _should_retry_response(resp.status, content):
102165
break
103166

104167
return resp, content

tests/test_discovery.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,10 +440,10 @@ def test_appengine_memcache(self):
440440
self.orig_import = __import__
441441
self.mocked_api = mock.MagicMock()
442442

443-
def import_mock(name, *args):
443+
def import_mock(name, *args, **kwargs):
444444
if name == 'google.appengine.api':
445445
return self.mocked_api
446-
return self.orig_import(name, *args)
446+
return self.orig_import(name, *args, **kwargs)
447447

448448
import_fullname = '__builtin__.__import__'
449449
if sys.version_info[0] >= 3:

0 commit comments

Comments
 (0)