Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto sleep and retry requests when 429 #89

Closed
wants to merge 4 commits into from
Closed
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
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ Connecting

# Public apps (OAuth)
# Access_token is optional, if you don't have one you can use oauth_fetch_token (see below)
# For connecting to the v2 api:
api = bigcommerce.api.BigcommerceApi(client_id='', store_hash='', access_token='')
# For connecting to the v3 api:
api = bigcommerce.api.BigcommerceApi(client_id='', store_hash='', access_token='', api_path='/stores/{}/v3/{}'))

# Private apps (Basic Auth)
api = bigcommerce.api.BigcommerceApi(host='store.mybigcommerce.com', basic_auth=('username', 'api token'))
Expand Down
3 changes: 2 additions & 1 deletion bigcommerce/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@


class BigcommerceApi(object):
def __init__(self, host=None, basic_auth=None,
def __init__(self, host=None, api_path=None, basic_auth=None,
client_id=None, store_hash=None, access_token=None, rate_limiting_management=None):
self.api_service = os.getenv('BC_API_ENDPOINT', 'api.bigcommerce.com')
self.auth_service = os.getenv('BC_AUTH_SERVICE', 'login.bigcommerce.com')
Expand All @@ -14,6 +14,7 @@ def __init__(self, host=None, basic_auth=None,
self.connection = connection.Connection(host, basic_auth)
elif client_id and store_hash:
self.connection = connection.OAuthConnection(client_id, store_hash, access_token, self.api_service,
api_path=api_path,
rate_limiting_management=rate_limiting_management)
else:
raise Exception("Must provide either (client_id and store_hash) or (host and basic_auth)")
Expand Down
29 changes: 28 additions & 1 deletion bigcommerce/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ def _run_method(self, method, url, data=None, query=None, headers=None):
if headers is None:
headers = {}

# Support v3
if self.api_path and 'v3' in self.api_path:
if url is 'orders':
self.api_path = self.api_path.replace('v3', 'v2')
else:
url = 'catalog/{}'.format(url)

# make full path if not given
if url and url[:4] != "http":
if url[0] == '/': # can call with /resource if you want
Expand Down Expand Up @@ -156,6 +163,9 @@ def _handle_response(self, url, res, suppress_empty=True):
if res.status_code in (200, 201, 202):
try:
result = res.json()
# Support v3
if self.api_path and 'v3' in self.api_path:
result = result['data'] # TODO ignore meta field for now
except Exception as e: # json might be invalid, or store might be down
e.message += " (_handle_response failed to decode JSON: " + str(res.content) + ")"
raise # TODO better exception
Expand Down Expand Up @@ -187,11 +197,12 @@ class OAuthConnection(Connection):
"""

def __init__(self, client_id, store_hash, access_token=None, host='api.bigcommerce.com',
api_path='/stores/{}/v2/{}', rate_limiting_management=None):
api_path=None, rate_limiting_management=None):
self.client_id = client_id
self.store_hash = store_hash
self.host = host
self.api_path = api_path
self.api_path = api_path if api_path else "/stores/{}/v2/{}"
self.timeout = 7.0 # can attach to session?
self.rate_limiting_management = rate_limiting_management

Expand All @@ -213,6 +224,22 @@ def _oauth_headers(cid, atoken):
return {'X-Auth-Client': cid,
'X-Auth-Token': atoken}

def make_request(self, *args, **kwargs):
try:
return super(OAuthConnection, self).make_request(*args, **kwargs)
except RateLimitingException as exc:
response = exc.response
autoretry = (self.rate_limiting_management
and self.rate_limiting_management.get('wait')
and self.rate_limiting_management.get('autoretry'))

if (autoretry and 'X-Rate-Limit-Time-Reset-Ms' in response.headers):
sleep(ceil(float(response.headers['X-Rate-Limit-Time-Reset-Ms'])/1000))
return super(OAuthConnection, self).make_request(
*args, **kwargs)
else:
raise

@staticmethod
def verify_payload(signed_payload, client_secret):
"""
Expand Down
1 change: 1 addition & 0 deletions bigcommerce/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@
from .store import *
from .tax_classes import *
from .time import *
from .variants import *
from .webhooks import *
25 changes: 21 additions & 4 deletions bigcommerce/resources/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,17 @@ def rules(self, id=None):
else:
return ProductRules.all(self.id, connection=self._connection)

def skus(self, id=None):
def skus(self, id=None, **kwargs):
if id:
return ProductSkus.get(self.id, id, connection=self._connection)
return ProductSkus.get(self.id, id, connection=self._connection, **kwargs)
else:
return ProductSkus.all(self.id, connection=self._connection)
return ProductSkus.all(self.id, connection=self._connection, **kwargs)

def variants(self, id=None, **kwargs):
if id:
return ProductVariants.get(self.id, id, connection=self._connection, **kwargs)
else:
return ProductVariants.all(self.id, connection=self._connection, **kwargs)

def videos(self, id=None):
if id:
Expand Down Expand Up @@ -99,7 +105,9 @@ class ProductImages(ListableApiSubResource, CreateableApiSubResource,
count_resource = 'products/images'


class ProductOptions(ListableApiSubResource):
class ProductOptions(ListableApiSubResource, CreateableApiSubResource,
UpdateableApiSubResource, DeleteableApiSubResource,
CollectionDeleteableApiSubResource, CountableApiSubResource):
resource_name = 'options'
parent_resource = 'products'
parent_key = 'product_id'
Expand Down Expand Up @@ -132,6 +140,15 @@ class ProductSkus(ListableApiSubResource, CreateableApiSubResource,
count_resource = 'products/skus'


class ProductVariants(ListableApiSubResource, CreateableApiSubResource,
UpdateableApiSubResource, DeleteableApiSubResource,
CollectionDeleteableApiSubResource, CountableApiSubResource):
resource_name = 'variants'
parent_resource = 'products'
parent_key = 'product_id'
count_resource = 'products/variants'


class ProductVideos(ListableApiSubResource, CountableApiSubResource,
CreateableApiSubResource, DeleteableApiSubResource,
CollectionDeleteableApiSubResource):
Expand Down
10 changes: 10 additions & 0 deletions bigcommerce/resources/variants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from .base import *


class Variants(ListableApiResource, CreateableApiSubResource,
UpdateableApiSubResource, DeleteableApiSubResource,
CollectionDeleteableApiSubResource, CountableApiSubResource):
resource_name = 'variants'
parent_resource = 'products'
parent_key = 'product_id'
count_resource = 'products/variants'
36 changes: 35 additions & 1 deletion tests/test_connection.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
import unittest
from bigcommerce.connection import Connection, OAuthConnection
from bigcommerce.exception import ServerException, ClientRequestException, RedirectionException
from bigcommerce.exception import ServerException, ClientRequestException, RedirectionException, RateLimitingException
from mock import patch, MagicMock


Expand Down Expand Up @@ -152,3 +152,37 @@ def test_fetch_token(self):
},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)

def test_handle_rate_limit(self):
client_id = 'abc123'
client_secret = '123abc'
code = 'hellosecret'
context = 'stores/abc'
scope = 'store_v2_products'
redirect_uri = 'http://localhost/callback'
result = {'access_token': '12345abcdef'}
connection = OAuthConnection(
client_id,
store_hash='abc',
rate_limiting_management={
'wait': True,
'autoretry': True
}
)
connection._run_method = MagicMock()
connection._run_method.return_value = MagicMock(
status_code=429,
reason='foo',
headers={
'X-Rate-Limit-Time-Reset-Ms': '300',
'X-Rate-Limit-Time-Window-Ms': '5000',
'X-Rate-Limit-Requests-Left': '6',
'X-Rate-Limit-Requests-Quota': '25'
},
content=''
)

with self.assertRaises(RateLimitingException ):
connection.make_request('POST', 'wathever')

self.assertEqual(connection._run_method.call_count, 2)