Skip to content

Commit d829708

Browse files
[CIVIS-11753] FIX retries: recursion error and wait strategy (#525)
* ENH verify user-provided retrying is a tenacity.Retrying instance * FIX use a new tenacity.Retrying instance in each API call * ENH update civis api spec * SEC ignore GHSA-4xh5-x5gv-qwph for pip * ENH wait strategy to add base on top of retry-after * REF break out _utils.py into _retries.py and _get_api_key.py * ENH apply retries to remaining HTTP requests * ENH re-raise the original exception, not tenacity's RetryError * REF use the defined retrying strategy in civis/io/_files.py * REF drop unused constant * TST no recursion at retry_request * FIX retries in civis/io/_files.py * TST placeholders for more tests needed * TST retries on _single_upload and _multipart_upload * ENH wait at least retry-after seconds * TST fix file read behavior for windows test * FIX import tenacity * MAINT update changelog
1 parent 6c503cc commit d829708

File tree

16 files changed

+215
-273
lines changed

16 files changed

+215
-273
lines changed

.circleci/config.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ workflows:
9494
pip install --progress-bar off -r docs/requirements.txt && \
9595
pip install --progress-bar off -e ".[dev-core,dev-civisml]" && \
9696
pip-audit --version && \
97-
pip-audit --skip-editable
97+
pip-audit --skip-editable \
98+
--ignore-vuln GHSA-4xh5-x5gv-qwph # https://github.com/pypa/pip/issues/13607
9899
- pre-build:
99100
name: twine
100101
command-run: |

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
99
### Added
1010

1111
### Changed
12+
- Updated retries for Civis API calls under `civis.APIClient`
13+
to wait at least the `Retry-After` duration from the response header,
14+
instead of potentially less than that. (#525)
1215

1316
### Deprecated
1417

1518
### Removed
1619

1720
### Fixed
18-
21+
- Fixed retries for Civis API calls under `civis.APIClient`
22+
that would lead to a recursion error. (#525)
1923
- Updated documentation for the `preview_rows` argument of `civis.io.query_civis` to
2024
have the correct maximum of 1000. (#523)
2125

src/civis/_get_api_key.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import os
2+
3+
4+
def get_api_key(api_key):
5+
"""Pass-through if `api_key` is not None otherwise tries the CIVIS_API_KEY
6+
environment variable.
7+
"""
8+
if api_key is not None: # always prefer user given one
9+
return api_key
10+
api_key = os.environ.get("CIVIS_API_KEY", None)
11+
if api_key is None:
12+
raise EnvironmentError(
13+
"No Civis API key found. Please store in "
14+
"CIVIS_API_KEY environment variable"
15+
)
16+
return api_key
Lines changed: 30 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
import os
32

43
import tenacity
54
from tenacity.wait import wait_base
@@ -19,36 +18,32 @@
1918
wait=tenacity.wait_random_exponential(multiplier=2, max=60),
2019
stop=(tenacity.stop_after_delay(600) | tenacity.stop_after_attempt(10)),
2120
retry_error_callback=lambda retry_state: retry_state.outcome.result(),
21+
reraise=True,
2222
)
2323
"""
2424

25-
# Explicitly set the available globals and locals
26-
# to mitigate risk of unwanted code execution
27-
DEFAULT_RETRYING = eval( # nosec
28-
DEFAULT_RETRYING_STR,
29-
{"tenacity": tenacity, "__builtins__": {}}, # globals
30-
{}, # locals
31-
)
32-
3325

34-
def get_api_key(api_key):
35-
"""Pass-through if `api_key` is not None otherwise tries the CIVIS_API_KEY
36-
environment variable.
37-
"""
38-
if api_key is not None: # always prefer user given one
39-
return api_key
40-
api_key = os.environ.get("CIVIS_API_KEY", None)
41-
if api_key is None:
42-
raise EnvironmentError(
43-
"No Civis API key found. Please store in "
44-
"CIVIS_API_KEY environment variable"
45-
)
46-
return api_key
26+
def get_default_retrying():
27+
"""Return a new instance of the default tenacity.Retrying."""
28+
# Explicitly set the available globals and locals
29+
# to mitigate risk of unwanted code execution
30+
return eval( # nosec
31+
DEFAULT_RETRYING_STR,
32+
{"tenacity": tenacity, "__builtins__": {}}, # globals
33+
{}, # locals
34+
)
4735

4836

4937
def retry_request(method, prepared_req, session, retrying=None):
5038
retry_conditions = None
51-
retrying = retrying if retrying else DEFAULT_RETRYING
39+
40+
# New tenacity.Retrying instance needed, whether it's a copy of the user-provided
41+
# one or it's one based on civis-python's default settings.
42+
retrying = retrying.copy() if retrying else get_default_retrying()
43+
44+
# If retries are exhausted,
45+
# raise the last exception encountered, not tenacity's RetryError.
46+
retrying.reraise = True
5247

5348
def _make_request(req, sess):
5449
"""send the prepared session request"""
@@ -66,33 +61,31 @@ def _make_request(req, sess):
6661

6762
if retry_conditions:
6863
retrying.retry = retry_conditions
69-
retrying.wait = wait_for_retry_after_header(fallback=retrying.wait)
64+
retrying.wait = wait_at_least_retry_after_header(base=retrying.wait)
7065
response = retrying(_make_request, prepared_req, session)
7166
return response
7267

7368
response = _make_request(prepared_req, session)
7469
return response
7570

7671

77-
class wait_for_retry_after_header(wait_base):
78-
"""Wait strategy that first looks for Retry-After header. If not
79-
present it uses the fallback strategy as the wait param"""
72+
class wait_at_least_retry_after_header(wait_base):
73+
"""Wait strategy for at least `Retry-After` seconds (if present from header)"""
8074

81-
def __init__(self, fallback):
82-
self.fallback = fallback
75+
def __init__(self, base):
76+
self.base = base
8377

8478
def __call__(self, retry_state):
8579
# retry_state is an instance of tenacity.RetryCallState.
8680
# The .outcome property contains the result/exception
8781
# that came from the underlying function.
88-
result_headers = retry_state.outcome._result.headers
89-
retry_after = result_headers.get("Retry-After") or result_headers.get(
90-
"retry-after"
91-
)
82+
headers = retry_state.outcome._result.headers
9283

9384
try:
94-
log.info("Retrying after {} seconds".format(retry_after))
95-
return int(retry_after)
85+
retry_after = float(
86+
headers.get("Retry-After") or headers.get("retry-after") or "0.0"
87+
)
9688
except (TypeError, ValueError):
97-
pass
98-
return self.fallback(retry_state)
89+
retry_after = 0.0
90+
# Wait at least retry_after seconds (compared to the user-specified wait).
91+
return max(retry_after, self.base(retry_state))

src/civis/base.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import civis
1212
from civis.response import PaginatedResponse, convert_response_data_type
13-
from civis._utils import retry_request, DEFAULT_RETRYING
13+
from civis._retries import retry_request
1414

1515
FINISHED = ["success", "succeeded"]
1616
FAILED = ["failed"]
@@ -159,16 +159,13 @@ def _make_request(self, method, path=None, params=None, data=None, **kwargs):
159159
params = self._handle_array_params(params)
160160

161161
with self._lock:
162-
if self._client._retrying is None:
163-
retrying = self._session_kwargs.pop("retrying", None)
164-
self._client._retrying = retrying if retrying else DEFAULT_RETRYING
165162
with open_session(self._session_kwargs["api_key"], self._headers) as sess:
166163
request = requests.Request(
167164
method, url, json=data, params=params, **kwargs
168165
)
169166
pre_request = sess.prepare_request(request)
170167
response = retry_request(
171-
method, pre_request, sess, self._client._retrying
168+
method, pre_request, sess, self._session_kwargs["retrying"]
172169
)
173170

174171
if response.status_code == 401:

src/civis/cli/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from civis.base import get_headers, open_session
4242
from civis.resources import get_api_spec, CACHED_SPEC_PATH
4343
from civis.resources._resources import parse_method_name
44-
from civis._utils import retry_request
44+
from civis._retries import retry_request
4545

4646

4747
_REPLACEABLE_COMMAND_CHARS = re.compile(r"[^A-Za-z0-9]+")

src/civis/cli/_cli_commands.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import civis
1515
from civis.io import file_to_civis, civis_to_file
1616
from civis.utils import job_logs
17+
from civis._retries import get_default_retrying
1718

1819

1920
# From http://patorjk.com/software/taag/#p=display&f=3D%20Diagonal&t=CIVIS
@@ -243,8 +244,10 @@ def notebooks_download_cmd(notebook_id, path):
243244
"""Download a notebook to a specified local path."""
244245
client = civis.APIClient()
245246
info = client.notebooks.get(notebook_id)
246-
response = requests.get(info["notebook_url"], stream=True, timeout=60)
247-
response.raise_for_status()
247+
for attempt in get_default_retrying():
248+
with attempt:
249+
response = requests.get(info["notebook_url"], stream=True, timeout=60)
250+
response.raise_for_status()
248251
chunk_size = 32 * 1024
249252
chunked = response.iter_content(chunk_size)
250253
with open(path, "wb") as f:

src/civis/client.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
from __future__ import annotations
22

3+
import collections
34
from functools import lru_cache
45
import logging
56
import textwrap
67
import warnings
7-
from typing import TYPE_CHECKING
8+
9+
import tenacity
810

911
import civis
1012
from civis.resources import generate_classes_maybe_cached
11-
from civis._utils import get_api_key, DEFAULT_RETRYING_STR
13+
from civis._get_api_key import get_api_key
14+
from civis._retries import DEFAULT_RETRYING_STR
1215
from civis.response import _RETURN_TYPES, find, find_one
1316

14-
if TYPE_CHECKING:
15-
import collections
16-
import tenacity
17-
1817

1918
_log = logging.getLogger(__name__)
2019

@@ -59,6 +58,9 @@ class APIClient:
5958
please note that you should leave the ``retry`` attribute unspecified,
6059
because the conditions under which retries apply are pre-determined
6160
-- see :ref:`retries` for details.
61+
In addition, the ``reraise`` attribute will be overridden to ``True``
62+
to raise the last exception (rather than tenacity's `RetryError`)
63+
if all retry attempts are exhausted.
6264
user_agent : str, optional
6365
A custom user agent string to use for requests made by this client.
6466
The user agent string will be appended with the Python version,
@@ -81,6 +83,11 @@ def __init__(
8183
)
8284
self._feature_flags = ()
8385
session_auth_key = get_api_key(api_key)
86+
if retries is not None and not isinstance(retries, tenacity.Retrying):
87+
raise TypeError(
88+
"If provided, the `retries` parameter must be "
89+
"a tenacity.Retrying instance."
90+
)
8491
self._session_kwargs = {
8592
"api_key": session_auth_key,
8693
"retrying": retries,
@@ -102,11 +109,6 @@ def __init__(
102109
cls(self._session_kwargs, client=self, return_type=return_type),
103110
)
104111

105-
# Don't create the `tenacity.Retrying` instance until we make the first
106-
# API call with this `APIClient` instance.
107-
# Once that happens, we keep re-using this `tenacity.Retrying` instance.
108-
self._retrying = None
109-
110112
@property
111113
def feature_flags(self):
112114
"""The feature flags for the current user.

0 commit comments

Comments
 (0)