Skip to content
93 changes: 93 additions & 0 deletions tests/test_rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import responses
import unittest

from tests.support import with_resource, with_fixture, characters

from twitter_ads.account import Account
from twitter_ads.campaign import Campaign
from twitter_ads.client import Client
from twitter_ads.cursor import Cursor
from twitter_ads.http import Request
from twitter_ads.resource import Resource
from twitter_ads import API_VERSION


@responses.activate
def test_rate_limit_cursor_class_access():
responses.add(responses.GET,
with_resource('/' + API_VERSION + '/accounts/2iqph'),
body=with_fixture('accounts_load'),
content_type='application/json')

responses.add(responses.GET,
with_resource('/' + API_VERSION + '/accounts/2iqph/campaigns'),
body=with_fixture('campaigns_all'),
content_type='application/json',
headers={
'x-account-rate-limit-limit': '10000',
'x-account-rate-limit-remaining': '9999',
'x-account-rate-limit-reset': '1546300800'
})

client = Client(
characters(40),
characters(40),
characters(40),
characters(40)
)

account = Account.load(client, '2iqph')

cursor = Campaign.all(account)
assert cursor is not None
assert isinstance(cursor, Cursor)
assert cursor.rate_limit is None
assert cursor.account_rate_limit == '10000'
assert cursor.account_rate_limit_remaining == '9999'
assert cursor.account_rate_limit_reset == '1546300800'


@responses.activate
def test_rate_limit_resource_class_access():
responses.add(responses.GET,
with_resource('/' + API_VERSION + '/accounts/2iqph'),
body=with_fixture('accounts_load'),
content_type='application/json')

responses.add(responses.GET,
with_resource('/' + API_VERSION + '/accounts/2iqph/campaigns/2wap7'),
body=with_fixture('campaigns_load'),
content_type='application/json',
headers={
'x-account-rate-limit-limit': '10000',
'x-account-rate-limit-remaining': '9999',
'x-account-rate-limit-reset': '1546300800'
})

client = Client(
characters(40),
characters(40),
characters(40),
characters(40)
)

account = Account.load(client, '2iqph')
campaign = Campaign.load(account, '2wap7')

resource = '/' + API_VERSION + '/accounts/2iqph/campaigns/2wap7'
params = {}

response = Request(client, 'get', resource, params=params).perform()
# from_response() is a staticmethod, so passing campaign instance as dummy.
# We can later change this test case to not call this manually
# once we changed existing classes to pass the header argument.
data = campaign.from_response(response.body['data'], response.headers)

assert data is not None
assert isinstance(data, Resource)
assert data.id == '2wap7'
assert data.entity_status == 'ACTIVE'
assert data.rate_limit is None
assert data.account_rate_limit == '10000'
assert data.account_rate_limit_remaining == '9999'
assert data.account_rate_limit_reset == '1546300800'
5 changes: 5 additions & 0 deletions twitter_ads/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

# from twitter_ads import *
from twitter_ads.http import Request
from twitter_ads.utils import extract_response_headers


class Cursor(object):
Expand Down Expand Up @@ -93,6 +94,10 @@ def __from_response(self, response):
if 'total_count' in response.body:
self._total_count = int(response.body['total_count'])

limits = extract_response_headers(response.headers)
for k in limits:
setattr(self, k, limits[k])

for item in response.body['data']:
if 'from_response' in dir(self._klass):
init_with = self._options.get('init_with', None)
Expand Down
36 changes: 3 additions & 33 deletions twitter_ads/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
else:
import http.client as httplib

import dateutil.parser
from datetime import datetime
from requests_oauthlib import OAuth1Session
from twitter_ads.utils import get_version
from twitter_ads.error import Error
Expand Down Expand Up @@ -124,12 +122,9 @@ def __init__(self, code, headers, **kwargs):
self._raw_body = kwargs.get('raw_body', None)

if headers.get('content-type') == 'application/gzip':
# hack because Twitter TON API doesn't return headers as it should
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Since this is not really correct I'm correcting this. re: PAEX-2015

# and instead returns a gzipp'd file rather than a gzipp encoded response
# Content-Encoding: gzip
# Content-Type: application/json
# instead it returns:
# Content-Type: application/gzip
# Async analytics data arrives as a gzipped file so decompress it on-the-fly.
# Note: might need to consider using zlib.decompressobj() instead
# in case data streams gets large enough (data size doesn't fit into memory at once)
raw_response_body = zlib.decompress(self._raw_body, 16 + zlib.MAX_WBITS).decode('utf-8')
else:
raw_response_body = self._raw_body
Expand All @@ -139,19 +134,6 @@ def __init__(self, code, headers, **kwargs):
except ValueError:
self._body = raw_response_body

if 'x-rate-limit-reset' in headers:
self._rate_limit = int(headers['x-rate-limit-limit'])
self._rate_limit_remaining = int(headers['x-rate-limit-remaining'])
self._rate_limit_reset = datetime.fromtimestamp(int(headers['x-rate-limit-reset']))
Copy link
Copy Markdown
Contributor Author

@smaeda-ks smaeda-ks Jul 18, 2019

Choose a reason for hiding this comment

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

I'm fine with not converting raw header values to a specific format like this. We can just leave it as is so that users can process as they want to. Plus, this doesn't necessarily be a breaking change as most of the users weren't able to access these values anyway unless they do a manual request approach.

elif 'x-cost-rate-limit-reset' in headers:
self._rate_limit = int(headers['x-cost-rate-limit-limit'])
self._rate_limit_remaining = int(headers['x-cost-rate-limit-remaining'])
self._rate_limit_reset = dateutil.parser.parse(headers['x-cost-rate-limit-reset'].first)
else:
self._rate_limit = None
self._rate_limit_remaining = None
self._rate_limit_reset = None

@property
def code(self):
return self._code
Expand All @@ -168,18 +150,6 @@ def body(self):
def raw_body(self):
return self._raw_body

@property
def rate_limit(self):
return self._rate_limit

@property
def rate_limit_remaining(self):
return self._rate_limit_remaining

@property
def rate_limit_reset(self):
return self._rate_limit_reset

@property
def error(self):
return True if (self._code >= 400 and self._code <= 599) else False
8 changes: 7 additions & 1 deletion twitter_ads/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from twitter_ads.http import Request
from twitter_ads.cursor import Cursor
from twitter_ads import API_VERSION
from twitter_ads.utils import extract_response_headers


def resource_property(klass, name, **kwargs):
Expand Down Expand Up @@ -42,12 +43,17 @@ def __init__(self, account):
def account(self):
return self._account

def from_response(self, response):
def from_response(self, response, headers=None):
Copy link
Copy Markdown
Contributor Author

@smaeda-ks smaeda-ks Jul 18, 2019

Choose a reason for hiding this comment

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

Adding a new argument headers where caller can pass response.headers as an optional argument.

Currently, caller passes only response body:

return self.from_response(response.body['data'])

so adding response headers in order to process rate limit headers:

return self.from_response(response.body['data'], response.headers)

see tests/test_rate_limit.py for this point.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Just reminder to follow up about debugging level headers and exposing those as well (X- headers)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yup, we can generalize the extract_rate_limit() method in the future to handle more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 to John's point. I believe there are plans to include rate limit headers to the async stats create endpoint. Given the current setup, this should be fairly trivial to add. Good stuff!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks! I just changed the func name to extract_response_headers() so this can be consistent for future use such as async one @tushdante mentioned.

"""
Populates a given objects attributes from a parsed JSON API response.
This helper handles all necessary type coercions as it assigns
attribute values.
"""
if headers is not None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What happens for the case where headers is None and the calling function i.e., return self.from_response(response.body['data'], response.headers) has the response.headers set? Ideally this should have something like cursor.account_rate_limit set to None as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@tushdante Thanks! That's a good point... I removed explicit "if" conditions from the extract_response_headers() and changed to use dict.get() method so they get either actual value from response or None in case if it's not present in response, and always accessible through instance variable.

limits = extract_response_headers(headers)
for k in limits:
setattr(self, k, limits[k])

for name in self.PROPERTIES:
attr = '_{0}'.format(name)
transform = self.PROPERTIES[name].get('transform', None)
Expand Down
14 changes: 14 additions & 0 deletions twitter_ads/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,17 @@ def validate_whole_hours(time):
# Times must be expressed in whole hours
if time.minute > 0 or time.second > 0:
raise ValueError("'start_time' and 'end_time' must be expressed in whole hours.")


def extract_response_headers(headers):
values = {}

values['rate_limit'] = headers.get('x-rate-limit-limit')
values['rate_limit_remaining'] = headers.get('x-rate-limit-remaining')
values['rate_limit_reset'] = headers.get('x-rate-limit-reset')

values['account_rate_limit'] = headers.get('x-account-rate-limit-limit')
values['account_rate_limit_remaining'] = headers.get('x-account-rate-limit-remaining')
values['account_rate_limit_reset'] = headers.get('x-account-rate-limit-reset')

return values