Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add retry decorator and function wrapper #17

Merged
merged 3 commits into from
Dec 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions ska_helpers/retry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""
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', 'retry_call']

import logging
from logging import StreamHandler

from .api import retry, retry_call

log = logging.getLogger(__name__)
log.addHandler(StreamHandler())
112 changes: 112 additions & 0 deletions ska_helpers/retry/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import functools
import logging
import random
import time


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, args=None, kwargs=None):
"""
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.
: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(*args, **kwargs)
except exceptions as e:
_tries -= 1
if not _tries:
raise

if logger is not None:
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 '
taldcroft marked this conversation as resolved.
Show resolved Hide resolved
f'in {_delay} seconds...')

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.
"""

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 decorator


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 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.
: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.
"""
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)
Empty file.
185 changes: 185 additions & 0 deletions ska_helpers/retry/tests/test_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
try:
from unittest.mock import create_autospec
except ImportError:
from mock import create_autospec # noqa

try:
from unittest.mock import MagicMock
except ImportError:
from mock import MagicMock

import time

import pytest

from ska_helpers.retry.api import retry_call
from ska_helpers.retry 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, args=[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, kwargs=kwargs)
except RuntimeError:
pass

assert result == kwargs['value']
assert f_mock.call_count == 1