From fe4d94051ac9624cf89e4960a5779852285711e2 Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 21:35:32 +0100 Subject: [PATCH 01/11] Added deterministic and repeatable generators. --- retrying.py | 90 ++++++++++++++++++++++++++++++++++++++++++++---- test_retrying.py | 33 +++++++++++++++++- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/retrying.py b/retrying.py index bcb7a9d..3667a41 100644 --- a/retrying.py +++ b/retrying.py @@ -11,13 +11,13 @@ ## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ## See the License for the specific language governing permissions and ## limitations under the License. - +import inspect import random -import six import sys import time import traceback +import six # sys.maxint / 2, since Python 3.2 doesn't have a sys.maxint... MAX_WAIT = 1073741823 @@ -41,8 +41,10 @@ def wrap_simple(f): @six.wraps(f) def wrapped_f(*args, **kw): + if dkw.get('deterministic_generators') and \ + (inspect.isasyncgenfunction(f) or inspect.isgeneratorfunction(f)): + return Retrying().call_async(f, *args, **kw) return Retrying().call(f, *args, **kw) - return wrapped_f return wrap_simple(dargs[0]) @@ -52,6 +54,9 @@ def wrap(f): @six.wraps(f) def wrapped_f(*args, **kw): + if dkw.get('deterministic_generators') and \ + (inspect.isasyncgenfunction(f) or inspect.isgeneratorfunction(f)): + return Retrying(*dargs, **dkw).call_async(f, *args, **kw) return Retrying(*dargs, **dkw).call(f, *args, **kw) return wrapped_f @@ -77,7 +82,8 @@ def __init__(self, wait_func=None, wait_jitter_max=None, before_attempts=None, - after_attempts=None): + after_attempts=None, + deterministic_generators=False): self._stop_max_attempt_number = 5 if stop_max_attempt_number is None else stop_max_attempt_number self._stop_max_delay = 100 if stop_max_delay is None else stop_max_delay @@ -92,7 +98,8 @@ def __init__(self, self._wait_jitter_max = 0 if wait_jitter_max is None else wait_jitter_max self._before_attempts = before_attempts self._after_attempts = after_attempts - + self._deterministic_generators = deterministic_generators + self._deterministic_offset = -1 # TODO add chaining of stop behaviors # stop behavior stop_funcs = [] @@ -215,6 +222,11 @@ def should_reject(self, attempt): return reject def call(self, fn, *args, **kwargs): + self._deterministic_offset = -1 + assert not self._deterministic_generators + + is_generator = inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn) + start_time = int(round(time.time() * 1000)) attempt_number = 1 while True: @@ -222,12 +234,26 @@ def call(self, fn, *args, **kwargs): self._before_attempts(attempt_number) try: - attempt = Attempt(fn(*args, **kwargs), attempt_number, False) + if is_generator: + # Here we do not know if the generator will fail. + # In order to avoid partial yield to the caller, which would + # produce partial data and then start from scratch upon error, + # we have to fetch the whole data and, in case of failures, we + # just recreate the result from scratch. + # We could also yield data incrementally and, when retrying, + # skip what we have already produced. + # This would require deterministic order of element production, though. + result = list(fn(*args, **kwargs)) + else: + result = fn(*args, **kwargs) + attempt = Attempt(result, attempt_number, False) except: tb = sys.exc_info() attempt = Attempt(tb, attempt_number, True) - + if not self.should_reject(attempt): + if is_generator: + return self._yelded_data(attempt) return attempt.get(self._wrap_exception) if self._after_attempts: @@ -249,6 +275,56 @@ def call(self, fn, *args, **kwargs): attempt_number += 1 + def call_async(self, fn, *args, **kwargs): + self._deterministic_offset = -1 + assert self._deterministic_generators + assert inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn) + + start_time = int(round(time.time() * 1000)) + attempt_number = 1 + while True: + if self._before_attempts: + self._before_attempts(attempt_number) + + try: + result = yield from self._deterministic_generation(fn, *args, **kwargs) + attempt = Attempt(result, attempt_number, False) + except: + tb = sys.exc_info() + attempt = Attempt(tb, attempt_number, True) + + if not self.should_reject(attempt): + return self._yelded_data(attempt) + + if self._after_attempts: + self._after_attempts(attempt_number) + + delay_since_first_attempt_ms = int(round(time.time() * 1000)) - start_time + if self.stop(attempt_number, delay_since_first_attempt_ms): + if not self._wrap_exception and attempt.has_exception: + # get() on an attempt with an exception should cause it to be raised, but raise just in case + raise attempt.get() + else: + raise RetryError(attempt) + else: + sleep = self.wait(attempt_number, delay_since_first_attempt_ms) + if self._wait_jitter_max: + jitter = random.random() * self._wait_jitter_max + sleep = sleep + max(0, jitter) + time.sleep(sleep / 1000.0) + + attempt_number += 1 + + def _yelded_data(self, attempt): + yield from attempt.get(self._wrap_exception) + + def _deterministic_generation(self, fn, *args, **kwargs): + for i, v in enumerate(fn(*args, **kwargs)): + if i <= self._deterministic_offset: + continue + yield v + self._deterministic_offset = i + class Attempt(object): """ diff --git a/test_retrying.py b/test_retrying.py index 8ce4ac3..46f7823 100644 --- a/test_retrying.py +++ b/test_retrying.py @@ -11,7 +11,7 @@ ## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ## See the License for the specific language governing permissions and ## limitations under the License. - +import datetime import time import unittest @@ -468,5 +468,36 @@ def _test_after(): self.assertTrue(TestBeforeAfterAttempts._attempt_number is 2) + +class TestGenerators(unittest.TestCase): + def test(self): + @retry(stop_max_delay=3000) + def _f(started: datetime.datetime): + for i in range(10): + if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): + raise ValueError + yield i + + self.assertEqual(list(range(10)), list(_f(datetime.datetime.now()))) + + def test_deterministic(self): + @retry(stop_max_delay=3000, deterministic_generators=True) + def _f(started: datetime.datetime): + for i in range(10): + if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): + raise ValueError + yield i + + self.assertEqual(list(range(10)), list(_f(datetime.datetime.now()))) + + def test_simple(self): + @retry(stop_max_delay=3000) + def _f(started: datetime.datetime): + if datetime.datetime.now() - started < datetime.timedelta(seconds=2): + raise ValueError + yield 'OK' + self.assertEqual(['OK'], list(_f(datetime.datetime.now()))) + + if __name__ == '__main__': unittest.main() From a3e6a9a022162a69fa85435313934efa2ad849c2 Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 21:50:23 +0100 Subject: [PATCH 02/11] Added README.rst snippet for generators. --- README.rst | 33 +++++++++++++++++++++++++++++++++ test_retrying.py | 18 +++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index ab5d6bd..6bcc1c5 100644 --- a/README.rst +++ b/README.rst @@ -144,6 +144,39 @@ We can also use the result of the function to alter the behavior of retrying. Any combination of stop, wait, etc. is also supported to give you the freedom to mix and match. +You might need to retry a (repeatable) generator function. The following generator functions are supported: +1. Generator functions whose values can be fetched as a whole. Ideal if the function may not preserve the order +and does not generates many elements +2. Generator functions whose values are always fetched in the same order, but generates many elements + +.. code-block:: python + + import datetime + + + @retry(stop_max_delay=3000) + def _few_elements(started: datetime.datetime): + for i in range(10): + if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): + raise ValueError + yield i + result = list(_few_elements(datetime.datetime.now())) # here we have [0, 1, ... 9] + + @retry(stop_max_delay=3000, deterministic_generators=True) + def _many_elements(started: datetime.datetime): + for i in range(sys.maxsize): + if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): + raise ValueError + yield i + + bounded_result = [] + for i in _many_elements(datetime.datetime.now()): + if i > 9: + break + bounded_result.append(i) + # Here bounded_result is [0, 1, ..., 9] and your RAM is preserved + + Contribute ---------- diff --git a/test_retrying.py b/test_retrying.py index 46f7823..e06a62e 100644 --- a/test_retrying.py +++ b/test_retrying.py @@ -12,6 +12,7 @@ ## See the License for the specific language governing permissions and ## limitations under the License. import datetime +import sys import time import unittest @@ -487,9 +488,24 @@ def _f(started: datetime.datetime): if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): raise ValueError yield i - self.assertEqual(list(range(10)), list(_f(datetime.datetime.now()))) + def test_deterministic_big_values(self): + # Do NOT use nondeterministic generators. You would get OOM. + @retry(stop_max_delay=3000, deterministic_generators=True) + def _f(started: datetime.datetime): + for i in range(sys.maxsize): + if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): + raise ValueError + yield i + + bounded_result = [] + for i in _f(datetime.datetime.now()): + if i > 9: + break + bounded_result.append(i) + self.assertEqual(list(range(10)), bounded_result) + def test_simple(self): @retry(stop_max_delay=3000) def _f(started: datetime.datetime): From 70aa4644e5955f1f89032f77d24abe0962d31a51 Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 21:53:01 +0100 Subject: [PATCH 03/11] Ignoring virtual env --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2e34cff..fb230e8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist *.pyc *.egg-info build +venv From 086941c24edd8d144301c7fc029bdeedbe5ffdd5 Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 21:55:08 +0100 Subject: [PATCH 04/11] Removed type hinting as Python 2.X does not support it. --- test_retrying.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test_retrying.py b/test_retrying.py index e06a62e..ddb1bf8 100644 --- a/test_retrying.py +++ b/test_retrying.py @@ -473,7 +473,7 @@ def _test_after(): class TestGenerators(unittest.TestCase): def test(self): @retry(stop_max_delay=3000) - def _f(started: datetime.datetime): + def _f(started): for i in range(10): if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): raise ValueError @@ -483,7 +483,7 @@ def _f(started: datetime.datetime): def test_deterministic(self): @retry(stop_max_delay=3000, deterministic_generators=True) - def _f(started: datetime.datetime): + def _f(started): for i in range(10): if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): raise ValueError @@ -493,7 +493,7 @@ def _f(started: datetime.datetime): def test_deterministic_big_values(self): # Do NOT use nondeterministic generators. You would get OOM. @retry(stop_max_delay=3000, deterministic_generators=True) - def _f(started: datetime.datetime): + def _f(started): for i in range(sys.maxsize): if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): raise ValueError @@ -508,7 +508,7 @@ def _f(started: datetime.datetime): def test_simple(self): @retry(stop_max_delay=3000) - def _f(started: datetime.datetime): + def _f(started): if datetime.datetime.now() - started < datetime.timedelta(seconds=2): raise ValueError yield 'OK' From 779128c7c12143d7adbcabc0718fdc39d5be0b9c Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 21:58:42 +0100 Subject: [PATCH 05/11] 'yield from' not supported by Python 2.X --- retrying.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/retrying.py b/retrying.py index 3667a41..f703302 100644 --- a/retrying.py +++ b/retrying.py @@ -287,8 +287,9 @@ def call_async(self, fn, *args, **kwargs): self._before_attempts(attempt_number) try: - result = yield from self._deterministic_generation(fn, *args, **kwargs) - attempt = Attempt(result, attempt_number, False) + for d in self._deterministic_generation(fn, *args, **kwargs): + yield d + attempt = Attempt(None, attempt_number, False) except: tb = sys.exc_info() attempt = Attempt(tb, attempt_number, True) From 203e720ae42297c680ac7a77cbe43520153c1abc Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 22:07:17 +0100 Subject: [PATCH 06/11] 'yield from' not supported by Python 2.X --- retrying.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/retrying.py b/retrying.py index f703302..40fca82 100644 --- a/retrying.py +++ b/retrying.py @@ -317,7 +317,8 @@ def call_async(self, fn, *args, **kwargs): attempt_number += 1 def _yelded_data(self, attempt): - yield from attempt.get(self._wrap_exception) + for d in attempt.get(self._wrap_exception): + yield d def _deterministic_generation(self, fn, *args, **kwargs): for i, v in enumerate(fn(*args, **kwargs)): From cc898c47ff53260746eb88c26969f032b3d636c6 Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 22:09:47 +0100 Subject: [PATCH 07/11] Fixed bug for Python 2.x. --- retrying.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/retrying.py b/retrying.py index 40fca82..b104749 100644 --- a/retrying.py +++ b/retrying.py @@ -295,8 +295,8 @@ def call_async(self, fn, *args, **kwargs): attempt = Attempt(tb, attempt_number, True) if not self.should_reject(attempt): - return self._yelded_data(attempt) - + self._yelded_data(attempt) + return if self._after_attempts: self._after_attempts(attempt_number) From 9878ae610c8ec1bbc6da3065ddf7d248e202ebd2 Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 22:14:55 +0100 Subject: [PATCH 08/11] Another fix for Python 2.x. --- retrying.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/retrying.py b/retrying.py index b104749..e602bb6 100644 --- a/retrying.py +++ b/retrying.py @@ -19,6 +19,15 @@ import six + +if sys.version_info >= (3, 0): + def _is_async(fn): + return inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn) +else: + def _is_async(fn): + return inspect.isgeneratorfunction(fn) + + # sys.maxint / 2, since Python 3.2 doesn't have a sys.maxint... MAX_WAIT = 1073741823 @@ -41,8 +50,7 @@ def wrap_simple(f): @six.wraps(f) def wrapped_f(*args, **kw): - if dkw.get('deterministic_generators') and \ - (inspect.isasyncgenfunction(f) or inspect.isgeneratorfunction(f)): + if dkw.get('deterministic_generators') and _is_async(f): return Retrying().call_async(f, *args, **kw) return Retrying().call(f, *args, **kw) return wrapped_f @@ -54,8 +62,7 @@ def wrap(f): @six.wraps(f) def wrapped_f(*args, **kw): - if dkw.get('deterministic_generators') and \ - (inspect.isasyncgenfunction(f) or inspect.isgeneratorfunction(f)): + if dkw.get('deterministic_generators') and _is_async(f): return Retrying(*dargs, **dkw).call_async(f, *args, **kw) return Retrying(*dargs, **dkw).call(f, *args, **kw) @@ -225,7 +232,7 @@ def call(self, fn, *args, **kwargs): self._deterministic_offset = -1 assert not self._deterministic_generators - is_generator = inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn) + is_generator = _is_async(fn) start_time = int(round(time.time() * 1000)) attempt_number = 1 @@ -278,7 +285,7 @@ def call(self, fn, *args, **kwargs): def call_async(self, fn, *args, **kwargs): self._deterministic_offset = -1 assert self._deterministic_generators - assert inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn) + assert _is_async(fn) start_time = int(round(time.time() * 1000)) attempt_number = 1 From db7e598adda4882dc59aae872c70002349585868 Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 22:16:35 +0100 Subject: [PATCH 09/11] Python 3.5 has async gen... --- retrying.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/retrying.py b/retrying.py index e602bb6..9474693 100644 --- a/retrying.py +++ b/retrying.py @@ -20,7 +20,7 @@ import six -if sys.version_info >= (3, 0): +if sys.version_info >= (3, 5): def _is_async(fn): return inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn) else: From bd6f568761b7c71f5fc695685ca9cf5e896e1c94 Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 22:21:56 +0100 Subject: [PATCH 10/11] Compatibility issues... --- .travis.yml | 1 + retrying.py | 2 +- test_retrying.py | 8 +++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8ce4a5a..63d4f08 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ python: - 3.3 - 3.4 - 3.5 + - 3.6 - pypy script: python setup.py test diff --git a/retrying.py b/retrying.py index 9474693..8adde2a 100644 --- a/retrying.py +++ b/retrying.py @@ -20,7 +20,7 @@ import six -if sys.version_info >= (3, 5): +if sys.version_info >= (3, 6): def _is_async(fn): return inspect.isasyncgenfunction(fn) or inspect.isgeneratorfunction(fn) else: diff --git a/test_retrying.py b/test_retrying.py index ddb1bf8..ed12092 100644 --- a/test_retrying.py +++ b/test_retrying.py @@ -491,10 +491,16 @@ def _f(started): self.assertEqual(list(range(10)), list(_f(datetime.datetime.now()))) def test_deterministic_big_values(self): + if sys.version >= (3, 0): + safe_range = range + else: + # noinspection PyUnresolvedReferences + safe_range = xrange + # Do NOT use nondeterministic generators. You would get OOM. @retry(stop_max_delay=3000, deterministic_generators=True) def _f(started): - for i in range(sys.maxsize): + for i in safe_range(sys.maxsize): if i == 5 and datetime.datetime.now() - started < datetime.timedelta(seconds=2): raise ValueError yield i From c0e2a393793a05eac2d63e841b1e63999fdc2da2 Mon Sep 17 00:00:00 2001 From: fcracker79 Date: Tue, 5 Feb 2019 22:24:08 +0100 Subject: [PATCH 11/11] Fixed typo in tests. --- test_retrying.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_retrying.py b/test_retrying.py index ed12092..82adbf7 100644 --- a/test_retrying.py +++ b/test_retrying.py @@ -491,7 +491,7 @@ def _f(started): self.assertEqual(list(range(10)), list(_f(datetime.datetime.now()))) def test_deterministic_big_values(self): - if sys.version >= (3, 0): + if sys.version_info >= (3, 0): safe_range = range else: # noinspection PyUnresolvedReferences