Skip to content

Commit 2f8d968

Browse files
spyrbripkopac
authored andcommitted
Add support for rate limiting (#21)
* Add granular control over the conditions under which we retry a request * Add retry functionality and allow config * Update Readme * Bump version to 1.2.0 * fixup! Add retry functionality and allow config * Review fixes
1 parent 0595f4e commit 2f8d968

File tree

7 files changed

+99
-12
lines changed

7 files changed

+99
-12
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ This throws error or returns `<Ping{data='pong!'}>`
6565
You can also pass to the Config initializer:
6666
* `request_timeout=` sets timeout for requests (seconds), default: none (see [requests docs](http://docs.python-requests.org/en/master/user/quickstart/#timeouts) for details)
6767

68+
### Rate Limits & Exponential Backoff
69+
The library will keep retrying if the request exceeds the rate limit or if there's any network related error.
70+
By default, the request will be retried for 20 times (approximately 15 minutes) before finally giving up.
71+
72+
You can change the retry count from the Config initializer:
73+
74+
* `max_retries=` sets the maximum number of retries for failed requests, default: 20
75+
* `backoff_factor=` sets the exponential backoff factor, default: 2
76+
77+
Set max_retries 0 to disable it.
78+
Set backoff_factor 0 to disable it.
79+
6880
## Usage
6981

7082
The library is based on [promises](https://pypi.python.org/pypi/promise) (mechanism similar to futures).

chartmogul/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"""
3030

3131
__title__ = 'chartmogul'
32-
__version__ = '1.1.8'
32+
__version__ = '1.2.0'
3333
__build__ = 0x000000
3434
__author__ = 'ChartMogul Ltd'
3535
__license__ = 'MIT'

chartmogul/api/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
class Config:
66
uri = API_BASE + "/" + VERSION
77

8-
def __init__(self, account_token, secret_key, request_timeout=None):
8+
def __init__(self, account_token, secret_key, request_timeout=None, max_retries=20, backoff_factor=2):
99
self.auth = (account_token, secret_key)
1010
self.request_timeout = request_timeout
11+
self.max_retries = max_retries
12+
self.backoff_factor = backoff_factor

chartmogul/resource.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from json import dumps
33
from promise import Promise
44
from uritemplate import URITemplate
5+
from .retry_request import requests_retry_session
56
from .errors import APIError, ConfigurationError, ArgumentMissingError, annotateHTTPError
67
from .api.config import Config
78
from datetime import datetime, date
@@ -119,14 +120,14 @@ def _request(cls, config, method, http_verb, path, data=None, **kwargs):
119120
data = dumps(data, default=json_serial)
120121

121122
return Promise(lambda resolve, _:
122-
resolve(getattr(requests, http_verb)(
123-
config.uri + path,
124-
data=data,
125-
headers={'content-type': 'application/json'},
126-
params=params,
127-
auth=config.auth,
128-
timeout=config.request_timeout)
129-
)).then(cls._load).catch(annotateHTTPError)
123+
resolve(getattr(requests_retry_session(config.max_retries, config.backoff_factor), http_verb)(
124+
config.uri + path,
125+
data=data,
126+
headers={'content-type': 'application/json'},
127+
params=params,
128+
auth=config.auth,
129+
timeout=config.request_timeout)
130+
)).then(cls._load).catch(annotateHTTPError)
130131

131132
@classmethod
132133
def _expandPath(cls, path, kwargs):
@@ -160,7 +161,6 @@ def fc(cls, config, **kwargs):
160161
return cls._request(config, method, http_verb, pathTemp, **kwargs)
161162
return fc
162163

163-
164164
def _add_method(cls, method, http_verb, path=None):
165165
"""
166166
Dynamically define all possible actions.

chartmogul/retry_request.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import requests
2+
from requests.adapters import HTTPAdapter
3+
from requests.packages.urllib3.util.retry import Retry
4+
5+
METHOD_WHITELIST = ['HEAD', 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'TRACE']
6+
STATUS_FORCELIST = (429, 500, 502, 503, 504, 520, 524)
7+
8+
def requests_retry_session(retries=20, backoff_factor=2, session=None,):
9+
session = session or requests.Session()
10+
adapter = _retry_adapter(retries, backoff_factor)
11+
session.mount('https://', adapter)
12+
return session
13+
14+
def _retry_adapter(retries, backoff_factor):
15+
retry = Retry(
16+
total=retries,
17+
read=retries,
18+
connect=retries,
19+
status=retries,
20+
method_whitelist=METHOD_WHITELIST,
21+
status_forcelist=STATUS_FORCELIST,
22+
backoff_factor=backoff_factor,
23+
)
24+
return HTTPAdapter(max_retries=retry)

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
'marshmallow>=2.12.1',
2121
'future>=0.16.0',
2222
]
23-
test_requirements = ['mock>=1.0.1', 'requests-mock>=1.3.0', 'vcrpy>=1.11.1']
23+
test_requirements = ['mock>=1.0.1', 'requests-mock>=1.3.0', 'vcrpy>=1.11.1', 'httpretty>=0.9.5']
2424

2525
with open('chartmogul/__init__.py', 'r') as fd:
2626
version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]',

test/api/test_retry_request.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import unittest
2+
3+
import httpretty
4+
import chartmogul
5+
from chartmogul import Config, DataSource
6+
from datetime import date, datetime
7+
from requests.exceptions import RetryError
8+
from chartmogul.retry_request import requests_retry_session
9+
10+
class RetryRequestTestCase(unittest.TestCase):
11+
12+
@httpretty.activate
13+
def test_retry_request(self):
14+
httpretty.register_uri(
15+
httpretty.GET,
16+
"https://example:444/testing",
17+
responses=[
18+
httpretty.Response(body='{}', status=500),
19+
httpretty.Response(body='{}', status=200),
20+
]
21+
)
22+
23+
with self.assertRaises(RetryError):
24+
requests_retry_session(0).get('https://example:444/testing')
25+
26+
response = requests_retry_session(2, 0).get('https://example:444/testing')
27+
self.assertEqual(response.text, '{}')
28+
29+
@httpretty.activate
30+
def test_requests_retry_session_on_resource(self):
31+
httpretty.register_uri(
32+
httpretty.POST,
33+
"https://api.chartmogul.com/v1/data_sources",
34+
responses=[
35+
httpretty.Response(body='{}', status=500),
36+
httpretty.Response(body='{}', status=500),
37+
httpretty.Response(body='{}', status=500),
38+
httpretty.Response(body='{}', status=500),
39+
httpretty.Response(body='{}', status=200),
40+
]
41+
)
42+
43+
# max_retries set as 4
44+
# backoff_factor set as 0 to avoid waiting while testing
45+
config = Config("token", "secret", None, 4, 0)
46+
try:
47+
DataSource.create(config, data={ "test_date": date(2015, 1, 1) }).get()
48+
except RetryError:
49+
self.fail("request raised retryError unexpectedly!")

0 commit comments

Comments
 (0)