Skip to content

Commit 5c135f9

Browse files
author
Stephen Hoover
authored
[CIVP-10885] ENH Retry connection errors in CLI (#117)
* ENH Retry connection errors in CLI When retrieving the API spec in the Civis client CLI, use the same code as we use in the client library. This has the advantage of including automatic retries for connection failures, and automatic waiting for rate limit refreshes. Also modify `invoke` so that we include the same retries as we would if we were using the client library. * REF DRY API connection We were creating connections to the Civis API in multiple places. Centralize everything in a single `open_session` function, and use that everywhere. * ENH Change CLI's User-Agent to "civis-cli" Make the CLI's User-Agent string distinct from the Python client's User-Agent. The CLI will send a User-Agent string which starts with "civis-cli".
1 parent bcde594 commit 5c135f9

File tree

7 files changed

+81
-102
lines changed

7 files changed

+81
-102
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
77
### Changed
88
- Edited example for safer null value handling
99
- Make ``pubnub`` and ``joblib`` hard dependencies instead of optional dependencies (#110).
10+
- Retry network errors and wait for API rate limit refresh when using the CLI (#117).
11+
- The CLI now provides a User-Agent header which starts with "civis-cli" (#117)
1012
- Include ``pandas`` and ``sklearn``-dependent code in Travis CI tests.
1113

1214
### Added

civis/_utils.py

+47
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
from __future__ import absolute_import
2+
from builtins import super
3+
14
import re
25
import uuid
36

7+
import requests
8+
from requests.adapters import HTTPAdapter
9+
from requests.packages.urllib3.util import Retry
10+
11+
import civis
12+
413

514
UNDERSCORER1 = re.compile(r'(.)([A-Z][a-z]+)')
615
UNDERSCORER2 = re.compile('([a-z0-9])([A-Z])')
@@ -20,3 +29,41 @@ def camel_to_snake(word):
2029

2130
def to_camelcase(s):
2231
return re.sub(r'(^|_)([a-zA-Z])', lambda m: m.group(2).upper(), s)
32+
33+
34+
def open_session(api_key, max_retries=5, user_agent="civis-python"):
35+
"""Create a new Session which can connect with the Civis API"""
36+
civis_version = civis.__version__
37+
session = requests.Session()
38+
session.auth = (api_key, '')
39+
session_agent = session.headers.get('User-Agent', '')
40+
user_agent = "{}/{} {}".format(user_agent, civis_version, session_agent)
41+
session.headers.update({"User-Agent": user_agent.strip()})
42+
max_retries = AggressiveRetry(max_retries, backoff_factor=.75,
43+
status_forcelist=civis.civis.RETRY_CODES)
44+
adapter = HTTPAdapter(max_retries=max_retries)
45+
session.mount("https://", adapter)
46+
47+
return session
48+
49+
50+
class AggressiveRetry(Retry):
51+
# Subclass Retry so that it retries more things. In particular,
52+
# always retry API requests with a Retry-After header, regardless
53+
# of the verb.
54+
def is_retry(self, method, status_code, has_retry_after=False):
55+
""" Is this method/status code retryable? (Based on whitelists and control
56+
variables such as the number of total retries to allow, whether to
57+
respect the Retry-After header, whether this header is present, and
58+
whether the returned status code is on the list of status codes to
59+
be retried upon on the presence of the aforementioned header)
60+
"""
61+
if (self.total and
62+
self.respect_retry_after_header and
63+
has_retry_after and
64+
(status_code in self.RETRY_AFTER_STATUS_CODES)):
65+
return True
66+
67+
else:
68+
return super().is_retry(method=method, status_code=status_code,
69+
has_retry_after=has_retry_after)

civis/base.py

-24
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
import six
88
import warnings
99

10-
from requests.packages.urllib3.util import Retry
11-
1210
from civis.response import PaginatedResponse, convert_response_data_type
1311

1412
FINISHED = ['success', 'succeeded']
@@ -76,28 +74,6 @@ def get_base_url():
7674
return base_url
7775

7876

79-
class AggressiveRetry(Retry):
80-
# Subclass Retry so that it retries more things. In particular,
81-
# always retry API requests with a Retry-After header, regardless
82-
# of the verb.
83-
def is_retry(self, method, status_code, has_retry_after=False):
84-
""" Is this method/status code retryable? (Based on whitelists and control
85-
variables such as the number of total retries to allow, whether to
86-
respect the Retry-After header, whether this header is present, and
87-
whether the returned status code is on the list of status codes to
88-
be retried upon on the presence of the aforementioned header)
89-
"""
90-
if (self.total and
91-
self.respect_retry_after_header and
92-
has_retry_after and
93-
(status_code in self.RETRY_AFTER_STATUS_CODES)):
94-
return True
95-
96-
else:
97-
return super().is_retry(method=method, status_code=status_code,
98-
has_retry_after=has_retry_after)
99-
100-
10177
class Endpoint(object):
10278

10379
_lock = threading.Lock()

civis/civis.py

+2-18
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,9 @@
22
import logging
33
import os
44

5-
import requests
6-
from requests.adapters import HTTPAdapter
7-
8-
import civis
9-
from civis.base import AggressiveRetry
105
from civis.compat import lru_cache
116
from civis.resources import generate_classes_maybe_cached
7+
from civis._utils import open_session
128

139

1410
log = logging.getLogger(__name__)
@@ -299,19 +295,7 @@ def __init__(self, api_key=None, return_type='snake',
299295
"'pandas'")
300296
self._feature_flags = ()
301297
session_auth_key = _get_api_key(api_key)
302-
self._session = session = requests.session()
303-
session.auth = (session_auth_key, '')
304-
305-
civis_version = civis.__version__
306-
session_agent = session.headers.get('User-Agent', '')
307-
user_agent = "civis-python/{} {}".format(civis_version, session_agent)
308-
session.headers.update({"User-Agent": user_agent.strip()})
309-
310-
max_retries = AggressiveRetry(retry_total, backoff_factor=.75,
311-
status_forcelist=RETRY_CODES)
312-
adapter = HTTPAdapter(max_retries=max_retries)
313-
314-
session.mount("https://", adapter)
298+
self._session = session = open_session(session_auth_key, retry_total)
315299

316300
classes = generate_classes_maybe_cached(local_api_spec,
317301
session_auth_key,

civis/cli/__main__.py

+11-18
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@
2222

2323
import click
2424
from jsonref import JsonRef
25-
import requests
2625
import yaml
2726
from civis.cli._cli_commands import \
2827
civis_ascii_art, files_download_cmd, files_upload_cmd
28+
from civis.resources import get_api_spec
29+
from civis._utils import open_session
2930
from civis.compat import FileNotFoundError
3031

3132

@@ -34,6 +35,7 @@
3435
_OPENAPI_SPEC_URL = "https://api.civisanalytics.com/endpoints"
3536
_CACHED_SPEC_PATH = \
3637
os.path.join(os.path.expanduser('~'), ".civis_api_spec.json")
38+
CLI_USER_AGENT = 'civis-cli'
3739

3840

3941
class YAMLParamType(click.ParamType):
@@ -71,18 +73,14 @@ def convert(self, value, param, ctx):
7173
}
7274

7375

74-
def make_api_request_headers():
76+
def get_api_key():
7577
try:
76-
headers = {
77-
'Authorization': "Bearer {}".format(os.environ["CIVIS_API_KEY"])
78-
}
78+
return os.environ["CIVIS_API_KEY"]
7979
except KeyError:
8080
print("You must set the CIVIS_API_KEY environment variable.",
8181
file=sys.stderr)
8282
sys.exit(1)
8383

84-
return headers
85-
8684

8785
def camel_to_snake(s):
8886
return re.sub(r'([A-Z]+)', r'_\1', s).lower()
@@ -160,13 +158,13 @@ def invoke(method, path, op, *args, **kwargs):
160158

161159
# Make the request.
162160
request_info = dict(
163-
headers=make_api_request_headers(),
164161
params=query,
165162
json=body,
166163
url=_API_URL + path.format(**kwargs),
167164
method=method
168165
)
169-
response = requests.request(**request_info)
166+
with open_session(get_api_key(), user_agent=CLI_USER_AGENT) as sess:
167+
response = sess.request(**request_info)
170168

171169
# Print the response to stderr if there was an error.
172170
output_file = sys.stdout
@@ -192,7 +190,7 @@ def invoke(method, path, op, *args, **kwargs):
192190
print("Error parsing response: {}".format(e), file=sys.stderr)
193191

194192

195-
def retrieve_spec_dict():
193+
def retrieve_spec_dict(api_version="1.0"):
196194
"""Retrieve the API specification from a cached version or from Civis."""
197195

198196
refresh_spec = True
@@ -211,15 +209,10 @@ def retrieve_spec_dict():
211209

212210
# Download the spec and cache it in the user's home directory.
213211
if refresh_spec:
214-
resp = requests.get(_OPENAPI_SPEC_URL,
215-
headers=make_api_request_headers())
216-
assert resp.status_code == 200, \
217-
"Failure downloading API specification: %d %s" % \
218-
(resp.status_code, resp.reason)
212+
spec_dict = get_api_spec(get_api_key(), api_version=api_version,
213+
user_agent=CLI_USER_AGENT)
219214
with open(_CACHED_SPEC_PATH, "w") as f:
220-
f.write(resp.text)
221-
spec_dict = json.loads(resp.text, object_pairs_hook=OrderedDict)
222-
215+
json.dump(spec_dict, f)
223216
return spec_dict
224217

225218

civis/resources/_resources.py

+8-16
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@
99

1010
from jsonref import JsonRef
1111
import requests
12-
from requests.adapters import HTTPAdapter
1312

14-
import civis
15-
from civis.base import AggressiveRetry, Endpoint, get_base_url
13+
from civis.base import Endpoint, get_base_url
1614
from civis.compat import lru_cache
17-
from civis._utils import camel_to_snake, to_camelcase
15+
from civis._utils import camel_to_snake, to_camelcase, open_session
1816

1917

2018
API_VERSIONS = ["1.0"]
@@ -435,7 +433,7 @@ def parse_api_spec(api_spec, api_version, resources):
435433

436434

437435
@lru_cache(maxsize=4)
438-
def get_api_spec(api_key, api_version="1.0"):
436+
def get_api_spec(api_key, api_version="1.0", user_agent="civis-python"):
439437
"""Download the Civis API specification.
440438
441439
Parameters
@@ -445,19 +443,13 @@ def get_api_spec(api_key, api_version="1.0"):
445443
api_version : string, optional
446444
The version of endpoints to call. May instantiate multiple client
447445
objects with different versions. Currently only "1.0" is supported.
446+
user_agent : string, optional
447+
Provide this user agent to the the Civis API, along with an
448+
API client version tag and ``requests`` version tag.
448449
"""
449-
civis_version = civis.__version__
450-
session = requests.Session()
451-
session.auth = (api_key, '')
452-
session_agent = session.headers.get('User-Agent', '')
453-
user_agent = "civis-python/{} {}".format(civis_version, session_agent)
454-
session.headers.update({"User-Agent": user_agent.strip()})
455-
max_retries = AggressiveRetry(MAX_RETRIES, backoff_factor=.75,
456-
status_forcelist=civis.civis.RETRY_CODES)
457-
adapter = HTTPAdapter(max_retries=max_retries)
458-
session.mount("https://", adapter)
459450
if api_version == "1.0":
460-
response = session.get("{}endpoints".format(get_base_url()))
451+
with open_session(api_key, MAX_RETRIES, user_agent=user_agent) as sess:
452+
response = sess.get("{}endpoints".format(get_base_url()))
461453
else:
462454
msg = "API specification for api version {} cannot be found"
463455
raise ValueError(msg.format(api_version))

civis/tests/test_cli.py

+11-26
Original file line numberDiff line numberDiff line change
@@ -69,53 +69,38 @@ def test_generate_cli_civis(mock_retrieve_spec_dict):
6969
assert p.required
7070

7171

72-
@mock.patch("civis.cli.__main__.make_api_request_headers")
73-
@mock.patch("civis.cli.__main__.yaml")
74-
@mock.patch("civis.cli.__main__.requests.request")
75-
def test_blank_output(mock_request, mock_yaml, mock_make_api_request_headers):
72+
@mock.patch("civis.cli.__main__.open_session", autospec=True)
73+
def test_blank_output(mock_session):
7674
"""
7775
Test that endpoints that return blank results don't cause exceptions.
78-
79-
We mock make_api_request_headers because the invoke function get the API
80-
key from that, so this test will fail in environments where an API key
81-
isn't in the env (e.g., travis).
82-
83-
We mock yaml because otherwise yaml will complain about being able to
84-
serialize the mock response object.
8576
"""
86-
87-
mock_make_api_request_headers.return_value = {}
88-
8977
# The response object's json method will raise a ValueError when the output
9078
# is blank.
91-
mock_request.return_value.json.side_effect = ValueError()
79+
session_context = mock_session.return_value.__enter__.return_value
80+
session_context.request.return_value.json.side_effect = ValueError()
9281

9382
op = {"parameters": []}
9483
invoke("WIBBLE", "/wobble/wubble", op)
9584

9685

97-
@mock.patch("civis.cli.__main__.make_api_request_headers")
98-
@mock.patch("civis.cli.__main__.yaml")
99-
@mock.patch("civis.cli.__main__.requests.request")
100-
def test_parameter_case(mock_request, mock_yaml,
101-
mock_make_api_request_headers):
86+
@mock.patch("civis.cli.__main__.open_session", autospec=True)
87+
def test_parameter_case(mock_session):
10288
"""
10389
Test that parameter names are sent in camelCase rather than snake_case.
104-
105-
We mock yaml because otherwise yaml will complain about being able to
106-
serialize the mock response object.
10790
"""
91+
api_response = {'key': 'value'}
92+
session_context = mock_session.return_value.__enter__.return_value
93+
session_context.request.return_value.json.return_value = api_response
10894

10995
# To avoid needing CIVIS_API_KEY set in the environment.
110-
mock_make_api_request_headers.return_value = {}
11196
op = {"parameters": [{'name': 'firstParameter', 'in': 'query'},
11297
{'name': 'secondParameter', 'in': 'query'}]}
11398
invoke("WIBBLE", "/wobble/wubble", op,
11499
first_parameter='a', second_parameter='b')
115100

116-
mock_request.assert_called_with(
101+
mock_session.call_args[1]['user_agent'] = 'civis-cli'
102+
session_context.request.assert_called_with(
117103
url='https://api.civisanalytics.com/wobble/wubble',
118-
headers={},
119104
json={},
120105
params={'firstParameter': 'a', 'secondParameter': 'b'},
121106
method='WIBBLE')

0 commit comments

Comments
 (0)