Skip to content

Commit 21c9616

Browse files
authored
Automatic Rate-Limit Handling & CI Testing
Added automatic rate-limit handling and CI Testing through (TravisCI).
2 parents 9f990ae + 5ddae03 commit 21c9616

File tree

11 files changed

+635
-167
lines changed

11 files changed

+635
-167
lines changed

Makefile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,19 @@ tests: toxtest lint ;
3131

3232
.PHONY : ci
3333
ci :
34-
pytest
34+
pytest -m "not ratelimit"
3535

3636
.PHONY : toxtest
3737
toxtest : local/environment.sh tox.ini
3838
source local/environment.sh && tox
3939

4040
.PHONY : pytest
4141
pytest : local/environment.sh
42-
source local/environment.sh && pytest
42+
source local/environment.sh && pytest -m "not ratelimit"
43+
44+
.PHONY : pytest-rate-limit
45+
pytest-rate-limit : local/environment.sh
46+
source local/environment.sh && pytest -m "ratelimit"
4347

4448
.PHONY : lint
4549
lint :

README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ ciscosparkapi
44

55
*Simple, lightweight, scalable Python API wrapper for the Cisco Spark APIs*
66

7+
.. image:: https://img.shields.io/badge/license-MIT-blue.svg
8+
:target: https://github.com/CiscoDevNet/ciscosparkapi/blob/master/LICENSE
79
.. image:: https://img.shields.io/pypi/v/ciscosparkapi.svg
810
:target: https://pypi.python.org/pypi/ciscosparkapi
11+
.. image:: https://travis-ci.org/CiscoDevNet/ciscosparkapi.svg?branch=master
12+
:target: https://travis-ci.org/CiscoDevNet/ciscosparkapi
913
.. image:: https://readthedocs.org/projects/ciscosparkapi/badge/?version=latest
1014
:target: http://ciscosparkapi.readthedocs.io/en/latest/?badge=latest
1115

@@ -74,6 +78,8 @@ ciscosparkapi does all of this for you...
7478

7579
+ **Automatic and transparent pagination!**
7680

81+
+ **Automatic rate-limit handling!** *(wait|retry)*
82+
7783
+ Multipart encoding and uploading of local files
7884

7985
+ Auto-completion in your favorite IDE, descriptive exceptions, and so much

ciscosparkapi/__init__.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@
1212
from builtins import *
1313
from past.builtins import basestring
1414

15+
import logging
1516
import os
1617

1718
from ciscosparkapi.exceptions import ciscosparkapiException, SparkApiError
18-
from ciscosparkapi.restsession import RestSession
19+
from ciscosparkapi.restsession import (
20+
DEFAULT_SINGLE_REQUEST_TIMEOUT,
21+
DEFAULT_WAIT_ON_RATE_LIMIT,
22+
RestSession,
23+
)
1924
from ciscosparkapi.api.people import Person, PeopleAPI
2025
from ciscosparkapi.api.rooms import Room, RoomsAPI
2126
from ciscosparkapi.api.memberships import Membership, MembershipsAPI
@@ -44,11 +49,17 @@
4449
del get_versions
4550

4651

52+
# Package Constants
4753
DEFAULT_BASE_URL = 'https://api.ciscospark.com/v1/'
48-
DEFAULT_TIMEOUT = 60
4954
ACCESS_TOKEN_ENVIRONMENT_VARIABLE = 'SPARK_ACCESS_TOKEN'
5055

5156

57+
# Initialize Package Logging
58+
logger = logging.getLogger(__name__)
59+
logger.addHandler(logging.NullHandler())
60+
61+
62+
# Main Package Interface
5263
class CiscoSparkAPI(object):
5364
"""Cisco Spark API wrapper.
5465
@@ -84,7 +95,9 @@ class CiscoSparkAPI(object):
8495
"""
8596

8697
def __init__(self, access_token=None, base_url=DEFAULT_BASE_URL,
87-
timeout=DEFAULT_TIMEOUT):
98+
timeout=None,
99+
single_request_timeout=DEFAULT_SINGLE_REQUEST_TIMEOUT,
100+
wait_on_rate_limit=DEFAULT_WAIT_ON_RATE_LIMIT):
88101
"""Create a new CiscoSparkAPI object.
89102
90103
An access token must be used when interacting with the Cisco Spark API.
@@ -106,8 +119,13 @@ def __init__(self, access_token=None, base_url=DEFAULT_BASE_URL,
106119
base_url(basestring): The base URL to be prefixed to the
107120
individual API endpoint suffixes.
108121
Defaults to ciscosparkapi.DEFAULT_BASE_URL.
109-
timeout(int): Timeout (in seconds) for RESTful HTTP requests.
110-
Defaults to ciscosparkapi.DEFAULT_TIMEOUT.
122+
timeout(int): [deprecated] Timeout (in seconds) for RESTful HTTP
123+
requests. Defaults to ciscosparkapi.DEFAULT_TIMEOUT.
124+
single_request_timeout(int): Timeout (in seconds) for RESTful HTTP
125+
requests. Defaults to
126+
ciscosparkapi.DEFAULT_SINGLE_REQUEST_TIMEOUT.
127+
wait_on_rate_limit(bool): Enables or disables automatic rate-limit
128+
handling. Defaults to ciscosparkapi.DEFAULT_WAIT_ON_RATE_LIMIT.
111129
112130
Returns:
113131
CiscoSparkAPI: A new CiscoSparkAPI object.
@@ -119,25 +137,27 @@ def __init__(self, access_token=None, base_url=DEFAULT_BASE_URL,
119137
variable.
120138
121139
"""
122-
# Process args
123140
assert access_token is None or isinstance(access_token, basestring)
124-
assert isinstance(base_url, basestring)
125-
assert isinstance(timeout, int)
126-
spark_access_token = os.environ.get(ACCESS_TOKEN_ENVIRONMENT_VARIABLE)
127-
access_token = access_token if access_token else spark_access_token
141+
env_access_token = os.environ.get(ACCESS_TOKEN_ENVIRONMENT_VARIABLE)
142+
access_token = access_token if access_token else env_access_token
128143
if not access_token:
129144
error_message = "You must provide an Spark access token to " \
130145
"interact with the Cisco Spark APIs, either via " \
131146
"a SPARK_ACCESS_TOKEN environment variable " \
132147
"or via the access_token argument."
133148
raise ciscosparkapiException(error_message)
134-
session_args = {u'timeout': timeout}
135149

136150
# Create the API session
137151
# All of the API calls associated with a CiscoSparkAPI object will
138152
# leverage a single RESTful 'session' connecting to the Cisco Spark
139153
# cloud.
140-
self._session = RestSession(access_token, base_url, **session_args)
154+
self._session = RestSession(
155+
access_token,
156+
base_url,
157+
timeout=timeout,
158+
single_request_timeout=single_request_timeout,
159+
wait_on_rate_limit=wait_on_rate_limit
160+
)
141161

142162
# Spark API wrappers
143163
self.people = PeopleAPI(self._session)
@@ -163,3 +183,11 @@ def base_url(self):
163183
@property
164184
def timeout(self):
165185
return self._session.timeout
186+
187+
@property
188+
def single_request_timeout(self):
189+
return self._session.single_request_timeout
190+
191+
@property
192+
def wait_on_rate_limit(self):
193+
return self._session.wait_on_rate_limit

ciscosparkapi/api/accesstokens.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@
2828

2929
from ciscosparkapi.sparkdata import SparkData
3030
from ciscosparkapi.utils import (
31-
ERC,
3231
validate_base_url,
3332
check_response_code,
3433
extract_and_parse_json,
3534
)
35+
from ciscosparkapi.responsecodes import EXPECTED_RESPONSE_CODE
3636

3737

3838
__author__ = "Chris Lunsford"
@@ -156,7 +156,7 @@ def get(self, client_id, client_secret, code, redirect_uri):
156156
# API request
157157
response = requests.post(self._endpoint_url, data=data,
158158
**self._request_kwargs)
159-
check_response_code(response, ERC['POST'])
159+
check_response_code(response, EXPECTED_RESPONSE_CODE['POST'])
160160
json_data = extract_and_parse_json(response)
161161
# Return a AccessToken object created from the response JSON data
162162
return AccessToken(json_data)
@@ -194,7 +194,7 @@ def refresh(self, client_id, client_secret, refresh_token):
194194
# API request
195195
response = requests.post(self._endpoint_url, data=data,
196196
**self._request_kwargs)
197-
check_response_code(response, ERC['POST'])
197+
check_response_code(response, EXPECTED_RESPONSE_CODE['POST'])
198198
json_data = extract_and_parse_json(response)
199199
# Return a AccessToken object created from the response JSON data
200200
return AccessToken(json_data)

ciscosparkapi/exceptions.py

Lines changed: 86 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,53 +12,105 @@
1212
from builtins import *
1313
from past.builtins import basestring
1414

15+
import json
16+
17+
import requests
18+
19+
from collections import OrderedDict
20+
21+
from ciscosparkapi.responsecodes import SPARK_RESPONSE_CODES
22+
1523

1624
__author__ = "Chris Lunsford"
1725
__author_email__ = "[email protected]"
1826
__copyright__ = "Copyright (c) 2016 Cisco Systems, Inc."
1927
__license__ = "MIT"
2028

2129

22-
SPARK_RESPONSE_CODES = {
23-
200: "OK",
24-
204: "Member deleted.",
25-
400: "The request was invalid or cannot be otherwise served. An "
26-
"accompanying error message will explain further.",
27-
401: "Authentication credentials were missing or incorrect.",
28-
403: "The request is understood, but it has been refused or access is not "
29-
"allowed.",
30-
404: "The URI requested is invalid or the resource requested, such as a "
31-
"user, does not exist. Also returned when the requested format is "
32-
"not supported by the requested method.",
33-
409: "The request could not be processed because it conflicts with some "
34-
"established rule of the system. For example, a person may not be "
35-
"added to a room more than once.",
36-
500: "Something went wrong on the server.",
37-
503: "Server is overloaded with requests. Try again later."
38-
}
39-
40-
4130
class ciscosparkapiException(Exception):
4231
"""Base class for all ciscosparkapi package exceptions."""
4332

44-
def __init__(self, *args, **kwargs):
45-
super(ciscosparkapiException, self).__init__(*args, **kwargs)
33+
def __init__(self, *error_message_args, **error_data):
34+
super(ciscosparkapiException, self).__init__()
35+
36+
self.error_message_args = error_message_args
37+
self.error_data = OrderedDict(error_data)
38+
39+
@property
40+
def error_message(self):
41+
"""The error message created from the error message arguments."""
42+
if not self.error_message_args:
43+
return ""
44+
elif len(self.error_message_args) == 1:
45+
return str(self.error_message_args[0])
46+
elif len(self.error_message_args) > 1 \
47+
and isinstance(self.error_message_args[0], basestring):
48+
return self.error_message_args[0] % self.error_message_args[1:]
49+
else:
50+
return "; ".join(self.error_message_args)
51+
52+
def __repr__(self):
53+
"""String representation of the exception."""
54+
arg_list = self.error_message_args
55+
kwarg_list = [str(key) + "=" + repr(value)
56+
for key, value in self.error_data.items()]
57+
arg_string = ", ".join(arg_list + kwarg_list)
58+
59+
return self.__class__.__name__ + "(" + arg_string + ")"
60+
61+
def __str__(self):
62+
"""Human readable string representation of the exception."""
63+
return self.error_message + '\n' + \
64+
json.dumps(self.error_data, indent=4)
4665

4766

4867
class SparkApiError(ciscosparkapiException):
4968
"""Errors returned by requests to the Cisco Spark cloud APIs."""
5069

51-
def __init__(self, response_code, request=None, response=None):
52-
assert isinstance(response_code, int)
53-
self.response_code = response_code
54-
self.request = request
70+
def __init__(self, response):
71+
assert isinstance(response, requests.Response)
72+
73+
super(SparkApiError, self).__init__()
74+
75+
# Convenience data attributes
76+
self.request = response.request
5577
self.response = response
56-
response_text = SPARK_RESPONSE_CODES.get(response_code)
57-
if response_text:
58-
self.response_text = response_text
59-
error_message = "Response Code [{!s}] - {}".format(response_code,
60-
response_text)
61-
else:
62-
error_message = "Response Code [{!s}] - " \
63-
"Unknown Response Code".format(response_code)
64-
super(SparkApiError, self).__init__(error_message)
78+
self.response_code = response.status_code
79+
self.response_text = SPARK_RESPONSE_CODES.get(self.response_code,
80+
"Unknown Response Code")
81+
82+
# Error message and parameters
83+
self.error_message_args = [
84+
"Response Code [%s] - %s",
85+
self.response_code,
86+
self.response_text
87+
]
88+
89+
# Error Data
90+
self.error_data["response_code"] = self.response_code
91+
self.error_data["description"] = self.response_text
92+
if response.text:
93+
try:
94+
response_data = json.loads(response.text,
95+
object_pairs_hook=OrderedDict)
96+
except ValueError:
97+
self.error_data["response_body"] = response.text
98+
else:
99+
self.error_data["response_body"] = response_data
100+
101+
102+
class SparkRateLimitError(SparkApiError):
103+
"""Cisco Spark Rate-Limit exceeded Error."""
104+
105+
def __init__(self, response):
106+
assert isinstance(response, requests.Response)
107+
108+
super(SparkRateLimitError, self).__init__(response)
109+
110+
retry_after = response.headers.get('Retry-After')
111+
if retry_after:
112+
# Convenience data attributes
113+
self.retry_after = float(retry_after)
114+
115+
# Error Data
116+
self.error_data["retry_after"] = self.retry_after

ciscosparkapi/responsecodes.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# -*- coding: utf-8 -*-
2+
"""Cisco Spark Response Codes."""
3+
4+
5+
# Use future for Python v2 and v3 compatibility
6+
from __future__ import (
7+
absolute_import,
8+
division,
9+
print_function,
10+
unicode_literals,
11+
)
12+
from builtins import *
13+
from past.builtins import basestring
14+
15+
16+
SPARK_RESPONSE_CODES = {
17+
200: "OK",
18+
204: "Member deleted.",
19+
400: "The request was invalid or cannot be otherwise served. An "
20+
"accompanying error message will explain further.",
21+
401: "Authentication credentials were missing or incorrect.",
22+
403: "The request is understood, but it has been refused or access is not "
23+
"allowed.",
24+
404: "The URI requested is invalid or the resource requested, such as a "
25+
"user, does not exist. Also returned when the requested format is "
26+
"not supported by the requested method.",
27+
409: "The request could not be processed because it conflicts with some "
28+
"established rule of the system. For example, a person may not be "
29+
"added to a room more than once.",
30+
429: "Too many requests have been sent in a given amount of time and the "
31+
"request has been rate limited. A Retry-After header should be "
32+
"present that specifies how many seconds you need to wait before a "
33+
"successful request can be made.",
34+
500: "Something went wrong on the server.",
35+
503: "Server is overloaded with requests. Try again later."
36+
}
37+
38+
RATE_LIMIT_RESPONSE_CODE = 429
39+
40+
EXPECTED_RESPONSE_CODE = {
41+
'GET': 200,
42+
'POST': 200,
43+
'PUT': 200,
44+
'DELETE': 204
45+
}

0 commit comments

Comments
 (0)