aboutsummaryrefslogtreecommitdiffstats
path: root/library/python/retry/__init__.py
blob: 5520a70332aa5d850e953d1d2abea3494d1b377b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
import copy
import datetime
import functools
import itertools
import logging
import random
import time


"""
Retry library provides an ability to retry function calls in a configurable way.

To retry a certain function call use `retry_call` function. To make function auto-retriable use `retry`
or `retry_intrusive` decorators. Both `retry_call` and `retry` optionally accept retry configuration object
or its fields as kwargs. The `retry_intrusive` is designed for methods and uses intrusive configuration object.

>>> retry_call(foo)
>>> retry_call(foo, foo_args, foo_kwargs)
>>> retry_call(foo, foo_args, foo_kwargs, conf=conf)

>>> @retry()
>>> def foo(...):
>>>     ...

>>> @retry(conf)
>>> def foo(...):
>>>     ...

>>> class Obj(object):
>>>     def __init__(self):
>>>         self.retry_conf = conf
>>>
>>>     @retry_intrusive
>>>     def foo(self, ...):
>>>         ...

This library differs from its alternatives:
    * `retry` contrib library lacks time-based limits, reusable configuration objects and is generally less flexible
    * `retrying` contrib library is somewhat more complex, but also lacks reusable configuration objects
"""


DEFAULT_SLEEP_FUNC = time.sleep
LOGGER = logging.getLogger(__name__)


class RetryConf(object):
    """
    Configuration object defines retry behaviour and is composed of these fields:
        * `retriable` - function that decides if an exception should trigger retrying
        * `get_delay` - function that returns a number of seconds retrier must wait before doing the next attempt
        * `max_time` - maximum `datetime.timedelta` that can pass after the first call for any retry attempt to be done
        * `max_times` - maximum number of retry attempts (note retries, not tries/calls)
        * `handle_error` - function that is called for each failed call attempt
        * `logger` - logger object to record retry warnings with
        * `sleep` - custom sleep function to use for waiting

    >>> RetryConf(max_time=datetime.timedelta(seconds=30), max_times=10)

    Empty configuration retries indefinitely on any exceptions raised.

    By default `DEFAULT_CONF` if used, which retries indefinitely, waiting 1 sec with 1.2 backoff between attempts, and
    also logging with built-in logger object.

    Configuration must be cloned before modification to create separate configuration:

    >>> DEFAULT_CONF.clone()

    There are various methods that provide convenient clone-and-modify shortcuts and "retry recipes".
    """

    _PROPS = {
        "retriable": lambda e: True,
        "get_delay": lambda n, raised_after, last: 0,
        "max_time": None,
        "max_times": None,
        "handle_error": None,
        "logger": None,
        "sleep": DEFAULT_SLEEP_FUNC,
    }

    def __init__(self, **kwargs):
        for prop, default_value in self._PROPS.items():
            setattr(self, prop, default_value)
        self._set(**kwargs)

    def __repr__(self):
        return repr(self.__dict__)

    def clone(self, **kwargs):
        """
        Clone configuration.
        """

        obj = copy.copy(self)
        obj._set(**kwargs)
        return obj

    def on(self, *errors):
        """
        Clone and retry on specific exception types (retriable shortcut):

        >>> conf = conf.on(MyException, MyOtherException)
        """

        obj = self.clone()
        obj.retriable = lambda e: isinstance(e, errors)
        return obj

    def waiting(self, delay=0, backoff=1.0, jitter=0, limit=None):
        """
        Clone and wait between attempts with backoff, jitter and limit (get_delay shortcut):

        >>> conf = conf.waiting(delay)
        >>> conf = conf.waiting(delay, backoff=2.0)  # initial delay with backoff x2 on each attempt
        >>> conf = conf.waiting(delay, jitter=3)  # jitter from 0 to 3 seconds
        >>> conf = conf.waiting(delay, backoff=2.0, limit=60)  # delay with backoff, but not greater than a minute

        All these options can be combined together, of course.
        """

        def get_delay(n, raised_after, last):
            if n == 1:
                return delay

            s = last * backoff
            s += random.uniform(0, jitter)
            if limit is not None:
                s = min(s, limit)
            return s

        obj = self.clone()
        obj.get_delay = get_delay
        return obj

    def upto(self, seconds=0, **other_timedelta_kwargs):
        """
        Clone and do retry attempts only for some time (max_time shortcut):

        >>> conf = conf.upto(30)  # retrying for 30 seconds
        >>> conf = conf.upto(hours=1, minutes=20)  # retrying for 1:20

        Any `datetime.timedelta` kwargs can be used here.
        """

        obj = self.clone()
        obj.max_time = datetime.timedelta(seconds=seconds, **other_timedelta_kwargs)
        return obj

    def upto_retries(self, retries=0):
        """
        Set limit for retry attempts number (max_times shortcut):

        >>> conf = conf.upto_retries(10)
        """

        obj = self.clone()
        obj.max_times = retries
        return obj

    def _set(self, **kwargs):
        for prop, value in kwargs.items():
            if prop not in self._PROPS:
                continue
            setattr(self, prop, value)


DEFAULT_CONF = RetryConf(logger=LOGGER).waiting(1, backoff=1.2)


def retry_call(f, f_args=(), f_kwargs={}, conf=DEFAULT_CONF, **kwargs):
    """
    Retry function call.

    :param f:           function to be retried
    :param f_args:      target function args
    :param f_kwargs:    target function kwargs
    :param conf:        configuration
    """

    if kwargs:
        conf = conf.clone(**kwargs)
    return _retry(conf, functools.partial(f, *f_args, **f_kwargs))


def retry(conf=DEFAULT_CONF, **kwargs):
    """
    Retrying decorator.

    :param conf:        configuration
    """

    if kwargs:
        conf = conf.clone(**kwargs)

    def decorator(f):
        @functools.wraps(f)
        def wrapped(*f_args, **f_kwargs):
            return _retry(conf, functools.partial(f, *f_args, **f_kwargs))

        return wrapped

    return decorator


def retry_intrusive(f):
    """
    Retrying method decorator that uses an intrusive conf (obj.retry_conf).
    """

    @functools.wraps(f)
    def wrapped(obj, *f_args, **f_kwargs):
        assert hasattr(obj, "retry_conf"), "Object must have retry_conf attribute for decorator to run"
        return _retry(obj.retry_conf, functools.partial(f, obj, *f_args, **f_kwargs))

    return wrapped


def _retry(conf, f):
    start = datetime.datetime.now()
    delay = 0
    for n in itertools.count(1):
        try:
            return f()
        except Exception as error:
            raised_after = datetime.datetime.now() - start
            if conf.handle_error:
                conf.handle_error(error, n, raised_after)
            delay = conf.get_delay(n, raised_after, delay)
            retry_after = raised_after + datetime.timedelta(seconds=delay)
            retrying = (
                conf.retriable(error)
                and (conf.max_times is None or n <= conf.max_times)
                and (conf.max_time is None or retry_after <= conf.max_time)
            )
            if not retrying:
                raise
            if delay:
                conf.sleep(delay)
            if conf.logger:
                conf.logger.warning(
                    "Retrying (try %d) after %s (%s + %s sec) on %s: %s",
                    n,
                    retry_after,
                    raised_after,
                    delay,
                    error.__class__.__name__,
                    error,
                    exc_info=True,
                )