From c38679dd47d430e13cb44e5e87659e05800ccda6 Mon Sep 17 00:00:00 2001 From: Tom Aldcroft Date: Fri, 4 Dec 2020 11:06:41 -0500 Subject: [PATCH 1/3] Initial copy of retry package --- ska_helpers/retry/__init__.py | 18 +++ ska_helpers/retry/api.py | 101 ++++++++++++++ ska_helpers/retry/compat.py | 18 +++ ska_helpers/retry/tests/__init__.py | 0 ska_helpers/retry/tests/test_retry.py | 185 ++++++++++++++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 ska_helpers/retry/__init__.py create mode 100644 ska_helpers/retry/api.py create mode 100644 ska_helpers/retry/compat.py create mode 100644 ska_helpers/retry/tests/__init__.py create mode 100644 ska_helpers/retry/tests/test_retry.py diff --git a/ska_helpers/retry/__init__.py b/ska_helpers/retry/__init__.py new file mode 100644 index 0000000..45c44d9 --- /dev/null +++ b/ska_helpers/retry/__init__.py @@ -0,0 +1,18 @@ +__all__ = ['retry'] + +import logging + +from .api import retry + + +# Set default logging handler to avoid "No handler found" warnings. +try: # Python 2.7+ + from logging import NullHandler +except ImportError: + class NullHandler(logging.Handler): + + def emit(self, record): + pass + +log = logging.getLogger(__name__) +log.addHandler(NullHandler()) diff --git a/ska_helpers/retry/api.py b/ska_helpers/retry/api.py new file mode 100644 index 0000000..245e4e4 --- /dev/null +++ b/ska_helpers/retry/api.py @@ -0,0 +1,101 @@ +import logging +import random +import time + +from functools import partial + +from retry.compat import decorator + + +logging_logger = logging.getLogger(__name__) + + +def __retry_internal(f, exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, jitter=0, + logger=logging_logger): + """ + Executes a function and retries it if it failed. + + :param f: the function to execute. + :param exceptions: an exception or a tuple of exceptions to catch. default: Exception. + :param tries: the maximum number of attempts. default: -1 (infinite). + :param delay: initial delay between attempts. default: 0. + :param max_delay: the maximum value of delay. default: None (no limit). + :param backoff: multiplier applied to delay between attempts. default: 1 (no backoff). + :param jitter: extra seconds added to delay between attempts. default: 0. + fixed if a number, random if a range tuple (min, max) + :param logger: logger.warning(fmt, error, delay) will be called on failed attempts. + default: retry.logging_logger. if None, logging is disabled. + :returns: the result of the f function. + """ + _tries, _delay = tries, delay + while _tries: + try: + return f() + except exceptions as e: + _tries -= 1 + if not _tries: + raise + + if logger is not None: + logger.warning('%s, retrying in %s seconds...', e, _delay) + + time.sleep(_delay) + _delay *= backoff + + if isinstance(jitter, tuple): + _delay += random.uniform(*jitter) + else: + _delay += jitter + + if max_delay is not None: + _delay = min(_delay, max_delay) + + +def retry(exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, jitter=0, logger=logging_logger): + """Returns a retry decorator. + + :param exceptions: an exception or a tuple of exceptions to catch. default: Exception. + :param tries: the maximum number of attempts. default: -1 (infinite). + :param delay: initial delay between attempts. default: 0. + :param max_delay: the maximum value of delay. default: None (no limit). + :param backoff: multiplier applied to delay between attempts. default: 1 (no backoff). + :param jitter: extra seconds added to delay between attempts. default: 0. + fixed if a number, random if a range tuple (min, max) + :param logger: logger.warning(fmt, error, delay) will be called on failed attempts. + default: retry.logging_logger. if None, logging is disabled. + :returns: a retry decorator. + """ + + @decorator + def retry_decorator(f, *fargs, **fkwargs): + args = fargs if fargs else list() + kwargs = fkwargs if fkwargs else dict() + return __retry_internal(partial(f, *args, **kwargs), exceptions, tries, delay, max_delay, backoff, jitter, + logger) + + return retry_decorator + + +def retry_call(f, fargs=None, fkwargs=None, exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, + jitter=0, + logger=logging_logger): + """ + Calls a function and re-executes it if it failed. + + :param f: the function to execute. + :param fargs: the positional arguments of the function to execute. + :param fkwargs: the named arguments of the function to execute. + :param exceptions: an exception or a tuple of exceptions to catch. default: Exception. + :param tries: the maximum number of attempts. default: -1 (infinite). + :param delay: initial delay between attempts. default: 0. + :param max_delay: the maximum value of delay. default: None (no limit). + :param backoff: multiplier applied to delay between attempts. default: 1 (no backoff). + :param jitter: extra seconds added to delay between attempts. default: 0. + fixed if a number, random if a range tuple (min, max) + :param logger: logger.warning(fmt, error, delay) will be called on failed attempts. + default: retry.logging_logger. if None, logging is disabled. + :returns: the result of the f function. + """ + args = fargs if fargs else list() + kwargs = fkwargs if fkwargs else dict() + return __retry_internal(partial(f, *args, **kwargs), exceptions, tries, delay, max_delay, backoff, jitter, logger) diff --git a/ska_helpers/retry/compat.py b/ska_helpers/retry/compat.py new file mode 100644 index 0000000..f39510d --- /dev/null +++ b/ska_helpers/retry/compat.py @@ -0,0 +1,18 @@ +import functools + + +try: + from decorator import decorator +except ImportError: + def decorator(caller): + """ Turns caller into a decorator. + Unlike decorator module, function signature is not preserved. + + :param caller: caller(f, *args, **kwargs) + """ + def decor(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + return caller(f, *args, **kwargs) + return wrapper + return decor diff --git a/ska_helpers/retry/tests/__init__.py b/ska_helpers/retry/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ska_helpers/retry/tests/test_retry.py b/ska_helpers/retry/tests/test_retry.py new file mode 100644 index 0000000..64f45cd --- /dev/null +++ b/ska_helpers/retry/tests/test_retry.py @@ -0,0 +1,185 @@ +try: + from unittest.mock import create_autospec +except ImportError: + from mock import create_autospec + +try: + from unittest.mock import MagicMock +except ImportError: + from mock import MagicMock + +import time + +import pytest + +from retry.api import retry_call +from retry.api import retry + + +def test_retry(monkeypatch): + mock_sleep_time = [0] + + def mock_sleep(seconds): + mock_sleep_time[0] += seconds + + monkeypatch.setattr(time, 'sleep', mock_sleep) + + hit = [0] + + tries = 5 + delay = 1 + backoff = 2 + + @retry(tries=tries, delay=delay, backoff=backoff) + def f(): + hit[0] += 1 + 1 / 0 + + with pytest.raises(ZeroDivisionError): + f() + assert hit[0] == tries + assert mock_sleep_time[0] == sum( + delay * backoff ** i for i in range(tries - 1)) + + +def test_tries_inf(): + hit = [0] + target = 10 + + @retry(tries=float('inf')) + def f(): + hit[0] += 1 + if hit[0] == target: + return target + else: + raise ValueError + assert f() == target + + +def test_tries_minus1(): + hit = [0] + target = 10 + + @retry(tries=-1) + def f(): + hit[0] += 1 + if hit[0] == target: + return target + else: + raise ValueError + assert f() == target + + +def test_max_delay(monkeypatch): + mock_sleep_time = [0] + + def mock_sleep(seconds): + mock_sleep_time[0] += seconds + + monkeypatch.setattr(time, 'sleep', mock_sleep) + + hit = [0] + + tries = 5 + delay = 1 + backoff = 2 + max_delay = delay # Never increase delay + + @retry(tries=tries, delay=delay, max_delay=max_delay, backoff=backoff) + def f(): + hit[0] += 1 + 1 / 0 + + with pytest.raises(ZeroDivisionError): + f() + assert hit[0] == tries + assert mock_sleep_time[0] == delay * (tries - 1) + + +def test_fixed_jitter(monkeypatch): + mock_sleep_time = [0] + + def mock_sleep(seconds): + mock_sleep_time[0] += seconds + + monkeypatch.setattr(time, 'sleep', mock_sleep) + + hit = [0] + + tries = 10 + jitter = 1 + + @retry(tries=tries, jitter=jitter) + def f(): + hit[0] += 1 + 1 / 0 + + with pytest.raises(ZeroDivisionError): + f() + assert hit[0] == tries + assert mock_sleep_time[0] == sum(range(tries - 1)) + + +def test_retry_call(): + f_mock = MagicMock(side_effect=RuntimeError) + tries = 2 + try: + retry_call(f_mock, exceptions=RuntimeError, tries=tries) + except RuntimeError: + pass + + assert f_mock.call_count == tries + + +def test_retry_call_2(): + side_effect = [RuntimeError, RuntimeError, 3] + f_mock = MagicMock(side_effect=side_effect) + tries = 5 + result = None + try: + result = retry_call(f_mock, exceptions=RuntimeError, tries=tries) + except RuntimeError: + pass + + assert result == 3 + assert f_mock.call_count == len(side_effect) + + +def test_retry_call_with_args(): + + def f(value=0): + if value < 0: + return value + else: + raise RuntimeError + + return_value = -1 + result = None + f_mock = MagicMock(spec=f, return_value=return_value) + try: + result = retry_call(f_mock, fargs=[return_value]) + except RuntimeError: + pass + + assert result == return_value + assert f_mock.call_count == 1 + + +def test_retry_call_with_kwargs(): + + def f(value=0): + if value < 0: + return value + else: + raise RuntimeError + + kwargs = {'value': -1} + result = None + f_mock = MagicMock(spec=f, return_value=kwargs['value']) + try: + result = retry_call(f_mock, fkwargs=kwargs) + except RuntimeError: + pass + + assert result == kwargs['value'] + assert f_mock.call_count == 1 From e76939faaa0e5f6899a3b4bc4a4efb6ecf0fa526 Mon Sep 17 00:00:00 2001 From: Tom Aldcroft Date: Fri, 4 Dec 2020 11:07:25 -0500 Subject: [PATCH 2/3] Add license --- ska_helpers/retry/__init__.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ska_helpers/retry/__init__.py b/ska_helpers/retry/__init__.py index 45c44d9..6032061 100644 --- a/ska_helpers/retry/__init__.py +++ b/ska_helpers/retry/__init__.py @@ -1,3 +1,24 @@ +""" +Retry package initially copied from https://github.com/invl/retry. + +This project appears to be abandoned so moving it to ska_helpers. + +LICENSE: + +Copyright 2014 invl + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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. +""" __all__ = ['retry'] import logging From e8526cc44d9ae5ba18aee283cdecb57cb3e45fef Mon Sep 17 00:00:00 2001 From: Tom Aldcroft Date: Fri, 4 Dec 2020 12:07:29 -0500 Subject: [PATCH 3/3] Customize retry for Ska --- ska_helpers/retry/__init__.py | 17 ++------ ska_helpers/retry/api.py | 57 ++++++++++++++++----------- ska_helpers/retry/compat.py | 18 --------- ska_helpers/retry/tests/test_retry.py | 10 ++--- 4 files changed, 43 insertions(+), 59 deletions(-) delete mode 100644 ska_helpers/retry/compat.py diff --git a/ska_helpers/retry/__init__.py b/ska_helpers/retry/__init__.py index 6032061..767384b 100644 --- a/ska_helpers/retry/__init__.py +++ b/ska_helpers/retry/__init__.py @@ -19,21 +19,12 @@ See the License for the specific language governing permissions and limitations under the License. """ -__all__ = ['retry'] +__all__ = ['retry', 'retry_call'] import logging +from logging import StreamHandler -from .api import retry - - -# Set default logging handler to avoid "No handler found" warnings. -try: # Python 2.7+ - from logging import NullHandler -except ImportError: - class NullHandler(logging.Handler): - - def emit(self, record): - pass +from .api import retry, retry_call log = logging.getLogger(__name__) -log.addHandler(NullHandler()) +log.addHandler(StreamHandler()) diff --git a/ska_helpers/retry/api.py b/ska_helpers/retry/api.py index 245e4e4..2bb5a64 100644 --- a/ska_helpers/retry/api.py +++ b/ska_helpers/retry/api.py @@ -1,17 +1,14 @@ +import functools import logging import random import time -from functools import partial - -from retry.compat import decorator - logging_logger = logging.getLogger(__name__) -def __retry_internal(f, exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, jitter=0, - logger=logging_logger): +def __retry_internal(f, exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, + jitter=0, logger=logging_logger, args=None, kwargs=None): """ Executes a function and retries it if it failed. @@ -25,19 +22,28 @@ def __retry_internal(f, exceptions=Exception, tries=-1, delay=0, max_delay=None, fixed if a number, random if a range tuple (min, max) :param logger: logger.warning(fmt, error, delay) will be called on failed attempts. default: retry.logging_logger. if None, logging is disabled. + :param args: tuple, function args + :param kwargs: dict, function kwargs :returns: the result of the f function. """ _tries, _delay = tries, delay while _tries: try: - return f() + return f(*args, **kwargs) except exceptions as e: _tries -= 1 if not _tries: raise if logger is not None: - logger.warning('%s, retrying in %s seconds...', e, _delay) + call_args = list(args) + for key, val in kwargs.items(): + call_args.append(f'{key}={val}') + call_args_str = ', '.join(str(arg) for arg in call_args) + func_name = getattr(f, '__name__', 'func') + func_call = f'{func_name}({call_args_str})' + logger.warning(f'WARNING: {func_call} exception: {e}, retrying ' + f'in {_delay} seconds...') time.sleep(_delay) _delay *= backoff @@ -51,7 +57,8 @@ def __retry_internal(f, exceptions=Exception, tries=-1, delay=0, max_delay=None, _delay = min(_delay, max_delay) -def retry(exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, jitter=0, logger=logging_logger): +def retry(exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, jitter=0, + logger=logging_logger): """Returns a retry decorator. :param exceptions: an exception or a tuple of exceptions to catch. default: Exception. @@ -66,25 +73,25 @@ def retry(exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, ji :returns: a retry decorator. """ - @decorator - def retry_decorator(f, *fargs, **fkwargs): - args = fargs if fargs else list() - kwargs = fkwargs if fkwargs else dict() - return __retry_internal(partial(f, *args, **kwargs), exceptions, tries, delay, max_delay, backoff, jitter, - logger) + def decorator(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + return __retry_internal(f, exceptions, tries, delay, max_delay, + backoff, jitter, logger, args=args, kwargs=kwargs) + return wrapper - return retry_decorator + return decorator -def retry_call(f, fargs=None, fkwargs=None, exceptions=Exception, tries=-1, delay=0, max_delay=None, backoff=1, - jitter=0, +def retry_call(f, args=None, kwargs=None, exceptions=Exception, tries=-1, delay=0, + max_delay=None, backoff=1, jitter=0, logger=logging_logger): """ Calls a function and re-executes it if it failed. :param f: the function to execute. - :param fargs: the positional arguments of the function to execute. - :param fkwargs: the named arguments of the function to execute. + :param args: the positional arguments of the function to execute. + :param kwargs: the named arguments of the function to execute. :param exceptions: an exception or a tuple of exceptions to catch. default: Exception. :param tries: the maximum number of attempts. default: -1 (infinite). :param delay: initial delay between attempts. default: 0. @@ -96,6 +103,10 @@ def retry_call(f, fargs=None, fkwargs=None, exceptions=Exception, tries=-1, dela default: retry.logging_logger. if None, logging is disabled. :returns: the result of the f function. """ - args = fargs if fargs else list() - kwargs = fkwargs if fkwargs else dict() - return __retry_internal(partial(f, *args, **kwargs), exceptions, tries, delay, max_delay, backoff, jitter, logger) + if args is None: + args = [] + if kwargs is None: + kwargs = {} + + return __retry_internal(f, exceptions, tries, delay, max_delay, + backoff, jitter, logger, args=args, kwargs=kwargs) diff --git a/ska_helpers/retry/compat.py b/ska_helpers/retry/compat.py deleted file mode 100644 index f39510d..0000000 --- a/ska_helpers/retry/compat.py +++ /dev/null @@ -1,18 +0,0 @@ -import functools - - -try: - from decorator import decorator -except ImportError: - def decorator(caller): - """ Turns caller into a decorator. - Unlike decorator module, function signature is not preserved. - - :param caller: caller(f, *args, **kwargs) - """ - def decor(f): - @functools.wraps(f) - def wrapper(*args, **kwargs): - return caller(f, *args, **kwargs) - return wrapper - return decor diff --git a/ska_helpers/retry/tests/test_retry.py b/ska_helpers/retry/tests/test_retry.py index 64f45cd..8937967 100644 --- a/ska_helpers/retry/tests/test_retry.py +++ b/ska_helpers/retry/tests/test_retry.py @@ -1,7 +1,7 @@ try: from unittest.mock import create_autospec except ImportError: - from mock import create_autospec + from mock import create_autospec # noqa try: from unittest.mock import MagicMock @@ -12,8 +12,8 @@ import pytest -from retry.api import retry_call -from retry.api import retry +from ska_helpers.retry.api import retry_call +from ska_helpers.retry import retry def test_retry(monkeypatch): @@ -157,7 +157,7 @@ def f(value=0): result = None f_mock = MagicMock(spec=f, return_value=return_value) try: - result = retry_call(f_mock, fargs=[return_value]) + result = retry_call(f_mock, args=[return_value]) except RuntimeError: pass @@ -177,7 +177,7 @@ def f(value=0): result = None f_mock = MagicMock(spec=f, return_value=kwargs['value']) try: - result = retry_call(f_mock, fkwargs=kwargs) + result = retry_call(f_mock, kwargs=kwargs) except RuntimeError: pass