Skip to content

Commit 53d68a3

Browse files
authored
Server error tolerance, more verbose exceptions (#8)
* Server error tolerance, more verbose exceptions * Python3 fix * Python3.7 fix * Python3.7 fix * Python3.7 fix * Update version to 0.0.9, change warning string formatting * Minor fixes
1 parent b906033 commit 53d68a3

File tree

6 files changed

+141
-101
lines changed

6 files changed

+141
-101
lines changed

.travis.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ python:
77
- '3.4'
88
- '3.5'
99
- '3.6'
10-
- 3.7-dev
11-
- nightly
10+
#- 3.7-dev
11+
#- nightly
1212
install:
1313
- if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then pip install -r requirements-py2.txt; fi
1414
- pip install -r requirements.txt

constructor_io/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from .constructor_io import *
2-
__version__ = '0.0.8'
1+
from .constructor_io import * # noqa
2+
__version__ = '0.0.9'
33
VERSION = tuple(map(int, __version__.split('.')))

constructor_io/constructor_io.py

Lines changed: 99 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,42 @@
1+
import time
12
import requests
3+
import logging
24

35
try:
46
from urllib.parse import urlencode # Python 3
57
except ImportError:
68
from urllib import urlencode # Python 2
79

10+
logger = logging.getLogger(__name__)
11+
logger.addHandler(logging.NullHandler())
12+
13+
# In seconds. Will increase each iteration
14+
RETRY_TIMEOUT_IN_CASE_OF_SERVER_ERROR = 10
15+
816

917
class ConstructorError(Exception):
10-
pass
18+
def __init__(self, message=""):
19+
super(ConstructorError, self).__init__(
20+
"Undefined error with Constructor.io: " + str(message))
21+
22+
23+
class ConstructorInputError(ConstructorError):
24+
def __init__(self, message=""):
25+
super(Exception, self).__init__("Bad request: " + str(message))
26+
27+
28+
class ConstructorServerError(ConstructorError):
29+
def __init__(self, message=""):
30+
super(Exception, self).__init__("Server error: " + str(message))
1131

1232

1333
class ConstructorIO(object):
1434
def __init__(self, api_token, key=None, protocol="https",
15-
host="ac.cnstrc.com", autocomplete_key=None):
16-
"""
17-
If you use HTTPS, you need a different version of requests
18-
"""
19-
# Support backward capability after renaming `autocomplete_key` to `key`
35+
host="ac.cnstrc.com", autocomplete_key=None,
36+
server_error_retries=10):
37+
38+
# Support backward capability after
39+
# renaming `autocomplete_key` to `key`
2040
if key is None:
2141
key = autocomplete_key
2242
if key is None and autocomplete_key is None:
@@ -26,6 +46,7 @@ def __init__(self, api_token, key=None, protocol="https",
2646
self._key = key
2747
self._protocol = protocol
2848
self._host = host
49+
self._server_error_retries = server_error_retries
2950

3051
def _serialize_params(self, params, sort=False):
3152
"""
@@ -45,24 +66,42 @@ def _make_url(self, endpoint, params=None):
4566
return "{0}://{1}/{2}?{3}".format(self._protocol, self._host, endpoint,
4667
self._serialize_params(params))
4768

48-
def query(self, query_str):
69+
def __make_server_request(self, request_method, *args, **kwargs):
70+
retries_left = self._server_error_retries
71+
timeout = RETRY_TIMEOUT_IN_CASE_OF_SERVER_ERROR
72+
73+
while True:
74+
try:
75+
# Wrap server error codes as exceptions
76+
response = request_method(*args, **kwargs)
77+
if response.status_code // 100 == 5:
78+
raise ConstructorServerError(response.text)
79+
elif response.status_code // 100 == 4:
80+
raise ConstructorInputError(response.text)
81+
elif not response.status_code // 100 == 2:
82+
raise ConstructorError(response.text)
83+
return response
84+
except ConstructorServerError as error:
85+
# Retry in case of server error
86+
if retries_left <= 0:
87+
raise error
88+
timeout += RETRY_TIMEOUT_IN_CASE_OF_SERVER_ERROR
89+
logger.warning('%s Retrying in %d seconds. Retries left: %d',
90+
error, timeout, retries_left)
91+
retries_left -= 1
92+
time.sleep(timeout)
93+
94+
def query(self, query_str, *args, **kwargs):
4995
url = self._make_url("autocomplete/" + query_str)
50-
resp = requests.get(url)
51-
if resp.status_code != 200:
52-
raise ConstructorError(resp.text)
53-
else:
54-
return resp.json()
96+
resp = self.__make_server_request(requests.get, url, *args, **kwargs)
97+
return resp.json()
5598

5699
def verify(self):
57100
url = self._make_url("v1/verify")
58-
resp = requests.get(
59-
url,
60-
auth=(self._api_token, "")
61-
)
62-
if resp.status_code != 200:
63-
raise ConstructorError(resp.text)
64-
else:
65-
return resp.json()
101+
resp = self.__make_server_request(requests.get,
102+
url,
103+
auth=(self._api_token, ""))
104+
return resp.json()
66105

67106
def extract_params_from_kwargs(self, params, **kwargs):
68107
# The '_force' kwarg just indicates that `force` should be added
@@ -83,15 +122,11 @@ def add(self, item_name, autocomplete_section, **kwargs):
83122
url_params["force"] = 1
84123
request_method = getattr(requests, 'put')
85124
url = self._make_url("v1/item", url_params)
86-
resp = request_method(
87-
url,
88-
json=params,
89-
auth=(self._api_token, "")
90-
)
91-
if resp.status_code != 204:
92-
raise ConstructorError(resp.text)
93-
else:
94-
return True
125+
self.__make_server_request(request_method,
126+
url,
127+
json=params,
128+
auth=(self._api_token, ""))
129+
return True
95130

96131
def add_or_update(self, item_name, autocomplete_section, **kwargs):
97132
if not self._api_token:
@@ -114,15 +149,11 @@ def add_batch(self, items, autocomplete_section, **kwargs):
114149
request_method = getattr(requests, 'put')
115150
params = {"items": items, "autocomplete_section": autocomplete_section}
116151
url = self._make_url("v1/batch_items", url_params)
117-
resp = request_method(
118-
url,
119-
json=params,
120-
auth=(self._api_token, "")
121-
)
122-
if resp.status_code != 204:
123-
raise ConstructorError(resp.text)
124-
else:
125-
return True
152+
self.__make_server_request(request_method,
153+
url,
154+
json=params,
155+
auth=(self._api_token, ""))
156+
return True
126157

127158
def add_or_update_batch(self, items, autocomplete_section, **kwargs):
128159
if not self._api_token:
@@ -138,15 +169,11 @@ def remove(self, item_name, autocomplete_section):
138169
if not self._api_token:
139170
raise IOError(
140171
"You must have an API token to use the Remove method!")
141-
resp = requests.delete(
142-
url,
143-
json=params,
144-
auth=(self._api_token, "")
145-
)
146-
if resp.status_code != 204:
147-
raise ConstructorError(resp.text)
148-
else:
149-
return True
172+
self.__make_server_request(requests.delete,
173+
url,
174+
json=params,
175+
auth=(self._api_token, ""))
176+
return True
150177

151178
def remove_batch(self, items, autocomplete_section):
152179
if not self._api_token:
@@ -155,15 +182,11 @@ def remove_batch(self, items, autocomplete_section):
155182
url_params = {}
156183
params = {"items": items, "autocomplete_section": autocomplete_section}
157184
url = self._make_url("v1/batch_items", url_params)
158-
resp = requests.delete(
159-
url,
160-
json=params,
161-
auth=(self._api_token, "")
162-
)
163-
if resp.status_code != 204:
164-
raise ConstructorError(resp.text)
165-
else:
166-
return True
185+
self.__make_server_request(requests.delete,
186+
url,
187+
json=params,
188+
auth=(self._api_token, ""))
189+
return True
167190

168191
def modify(self, item_name, autocomplete_section, **kwargs):
169192
params = {"item_name": item_name,
@@ -184,15 +207,11 @@ def modify(self, item_name, autocomplete_section, **kwargs):
184207
if not self._api_token:
185208
raise IOError(
186209
"You must have an API token to use the Modify method!")
187-
resp = requests.put(
188-
url,
189-
json=params,
190-
auth=(self._api_token, "")
191-
)
192-
if resp.status_code != 204:
193-
raise ConstructorError(resp.text)
194-
else:
195-
return True
210+
self.__make_server_request(requests.put,
211+
url,
212+
json=params,
213+
auth=(self._api_token, ""))
214+
return True
196215

197216
def track_conversion(self, term, autocomplete_section, **kwargs):
198217
params = {
@@ -204,15 +223,11 @@ def track_conversion(self, term, autocomplete_section, **kwargs):
204223
url = self._make_url("v1/conversion")
205224
if not self._api_token:
206225
raise IOError("You must have an API token to track conversions!")
207-
resp = requests.post(
208-
url,
209-
json=params,
210-
auth=(self._api_token, "")
211-
)
212-
if resp.status_code != 204:
213-
raise ConstructorError(resp.text)
214-
else:
215-
return True
226+
self.__make_server_request(requests.post,
227+
url,
228+
json=params,
229+
auth=(self._api_token, ""))
230+
return True
216231

217232
def track_click_through(self, term, autocomplete_section, **kwargs):
218233
params = {
@@ -227,15 +242,11 @@ def track_click_through(self, term, autocomplete_section, **kwargs):
227242
if not self._api_token:
228243
raise IOError(
229244
"You must have an API token to track click throughs!")
230-
resp = requests.post(
231-
url,
232-
json=params,
233-
auth=(self._api_token, "")
234-
)
235-
if resp.status_code != 204:
236-
raise ConstructorError(resp.text)
237-
else:
238-
return True
245+
self.__make_server_request(requests.post,
246+
url,
247+
json=params,
248+
auth=(self._api_token, ""))
249+
return True
239250

240251
def track_search(self, term, **kwargs):
241252
params = {
@@ -246,12 +257,8 @@ def track_search(self, term, **kwargs):
246257
url = self._make_url("v1/search")
247258
if not self._api_token:
248259
raise IOError("You must have an API token to track searches!")
249-
resp = requests.post(
250-
url,
251-
json=params,
252-
auth=(self._api_token, "")
253-
)
254-
if resp.status_code != 204:
255-
raise ConstructorError(resp.text)
256-
else:
257-
return True
260+
self.__make_server_request(requests.post,
261+
url,
262+
json=params,
263+
auth=(self._api_token, ""))
264+
return True

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
PyYAML==3.11
21
argparse==1.2.1
32
contextlib2==0.4.0
43
funcsigs==0.4
@@ -8,4 +7,5 @@ requests==2.7.0
87
six==1.9.0
98
vcrpy==1.11.1
109
wrapt==1.10.5
11-
pytest==3.3.1
10+
pytest==3.3.1
11+
requests-mock==1.4.0

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from setuptools import setup
22

3-
VERSION = "0.0.8"
3+
VERSION = "0.0.9"
44

55
setup(
66
name="constructor-io",

tests/test_constructor.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import vcr
2+
import requests_mock
23
from unittest import TestCase
34

4-
from constructor_io.constructor_io import ConstructorIO
5+
from constructor_io.constructor_io import ConstructorIO, ConstructorError,\
6+
ConstructorServerError, ConstructorInputError
57

68
HTTPS_ARGS = {
79
"api_token": "my-api-token",
@@ -108,7 +110,8 @@ def test_add_or_update(self):
108110
assert resp2 is True
109111

110112
def test_add_batch(self):
111-
with my_vcr.use_cassette("fixtures/ac.cnstrc.com/add-batch-success.yaml"):
113+
with my_vcr.use_cassette(
114+
"fixtures/ac.cnstrc.com/add-batch-success.yaml"):
112115
constructor = ConstructorIO(**HTTPS_ARGS)
113116
items = [{"item_name": "new item1"}, {"item_name": "new_item2"}]
114117
resp = constructor.add_batch(
@@ -221,3 +224,33 @@ def test_click_through(self):
221224
autocomplete_section="Search Suggestions"
222225
)
223226
assert resp is True
227+
228+
def test_exceptions(self):
229+
constructor = ConstructorIO(**HTTPS_ARGS)
230+
with requests_mock.mock() as mock:
231+
# Test for 400 errors:
232+
mock.get("https://ac.cnstrc.com/autocomplete/a?key=my_api_key",
233+
status_code=403)
234+
with self.assertRaises(ConstructorInputError):
235+
constructor.query(query_str="a")
236+
237+
# Test for 500 errors
238+
mock.get("https://ac.cnstrc.com/autocomplete/a?key=my_api_key",
239+
status_code=500)
240+
constructor._server_error_retries = 0
241+
with self.assertRaises(ConstructorServerError):
242+
constructor.query(query_str="a")
243+
244+
# Assert exception payload inclusion
245+
self.assertEquals(str(ConstructorServerError("payload")),
246+
"Server error: payload")
247+
self.assertEquals(str(ConstructorInputError("payload")),
248+
"Bad request: payload")
249+
self.assertEquals(str(ConstructorError("payload")),
250+
"Undefined error with Constructor.io: payload")
251+
252+
# Make sure ConstructorError handling will handle other exceptions
253+
with self.assertRaises(ConstructorError):
254+
raise ConstructorServerError()
255+
with self.assertRaises(ConstructorError):
256+
raise ConstructorInputError()

0 commit comments

Comments
 (0)