blob: dbd44e0e1d761ddb1debef74db78c7141610e130 [file] [log] [blame]
# ***** BEGIN LICENSE BLOCK *****
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.
# ***** END LICENSE BLOCK *****
import time
from functools import wraps
from contextlib import contextmanager
import logging
import random
log = logging.getLogger(__name__)
def retrier(attempts=5, sleeptime=10, max_sleeptime=300, sleepscale=1.5, jitter=1):
"""
A generator function that sleeps between retries, handles exponential
backoff and jitter. The action you are retrying is meant to run after
retrier yields.
At each iteration, we sleep for sleeptime + random.randint(-jitter, jitter).
Afterwards sleeptime is multiplied by sleepscale for the next iteration.
Args:
attempts (int): maximum number of times to try; defaults to 5
sleeptime (float): how many seconds to sleep between tries; defaults to
60s (one minute)
max_sleeptime (float): the longest we'll sleep, in seconds; defaults to
300s (five minutes)
sleepscale (float): how much to multiply the sleep time by each
iteration; defaults to 1.5
jitter (int): random jitter to introduce to sleep time each iteration.
the amount is chosen at random between [-jitter, +jitter]
defaults to 1
Yields:
None, a maximum of `attempts` number of times
Example:
>>> n = 0
>>> for _ in retrier(sleeptime=0, jitter=0):
... if n == 3:
... # We did the thing!
... break
... n += 1
>>> n
3
>>> n = 0
>>> for _ in retrier(sleeptime=0, jitter=0):
... if n == 6:
... # We did the thing!
... break
... n += 1
... else:
... print "max tries hit"
max tries hit
"""
if jitter > sleeptime:
# To prevent negative sleep times
raise Exception('jitter ({}) must be less than sleep time ({})'.format(jitter, sleeptime))
sleeptime_real = sleeptime
for _ in range(attempts):
log.debug("attempt %i/%i", _ + 1, attempts)
yield sleeptime_real
if jitter:
sleeptime_real = sleeptime + random.randint(-jitter, jitter)
# our jitter should scale along with the sleeptime
jitter = int(jitter * sleepscale)
else:
sleeptime_real = sleeptime
sleeptime *= sleepscale
if sleeptime_real > max_sleeptime:
sleeptime_real = max_sleeptime
# Don't need to sleep the last time
if _ < attempts - 1:
log.debug("sleeping for %.2fs (attempt %i/%i)", sleeptime_real, _ + 1, attempts)
time.sleep(sleeptime_real)
def retry(action, attempts=5, sleeptime=60, max_sleeptime=5 * 60,
sleepscale=1.5, jitter=1, retry_exceptions=(Exception,),
cleanup=None, args=(), kwargs={}):
"""
Calls an action function until it succeeds, or we give up.
Args:
action (callable): the function to retry
attempts (int): maximum number of times to try; defaults to 5
sleeptime (float): how many seconds to sleep between tries; defaults to
60s (one minute)
max_sleeptime (float): the longest we'll sleep, in seconds; defaults to
300s (five minutes)
sleepscale (float): how much to multiply the sleep time by each
iteration; defaults to 1.5
jitter (int): random jitter to introduce to sleep time each iteration.
the amount is chosen at random between [-jitter, +jitter]
defaults to 1
retry_exceptions (tuple): tuple of exceptions to be caught. If other
exceptions are raised by action(), then these
are immediately re-raised to the caller.
cleanup (callable): optional; called if one of `retry_exceptions` is
caught. No arguments are passed to the cleanup
function; if your cleanup requires arguments,
consider using functools.partial or a lambda
function.
args (tuple): positional arguments to call `action` with
hwargs (dict): keyword arguments to call `action` with
Returns:
Whatever action(*args, **kwargs) returns
Raises:
Whatever action(*args, **kwargs) raises. `retry_exceptions` are caught
up until the last attempt, in which case they are re-raised.
Example:
>>> count = 0
>>> def foo():
... global count
... count += 1
... print count
... if count < 3:
... raise ValueError("count is too small!")
... return "success!"
>>> retry(foo, sleeptime=0, jitter=0)
1
2
3
'success!'
"""
assert callable(action)
assert not cleanup or callable(cleanup)
if max_sleeptime < sleeptime:
log.debug("max_sleeptime %d less than sleeptime %d" % (
max_sleeptime, sleeptime))
n = 1
for _ in retrier(attempts=attempts, sleeptime=sleeptime,
max_sleeptime=max_sleeptime, sleepscale=sleepscale,
jitter=jitter):
try:
logfn = log.info if n != 1 else log.debug
logfn("retry: Calling %s with args: %s, kwargs: %s, "
"attempt #%d" % (action, str(args), str(kwargs), n))
return action(*args, **kwargs)
except retry_exceptions:
log.debug("retry: Caught exception: ", exc_info=True)
if cleanup:
cleanup()
if n == attempts:
log.info("retry: Giving up on %s" % action)
raise
continue
finally:
n += 1
def retriable(*retry_args, **retry_kwargs):
"""
A decorator factory for retry(). Wrap your function in @retriable(...) to
give it retry powers!
Arguments:
Same as for `retry`, with the exception of `action`, `args`, and `kwargs`,
which are left to the normal function definition.
Returns:
A function decorator
Example:
>>> count = 0
>>> @retriable(sleeptime=0, jitter=0)
... def foo():
... global count
... count += 1
... print count
... if count < 3:
... raise ValueError("count too small")
... return "success!"
>>> foo()
1
2
3
'success!'
"""
def _retriable_factory(func):
@wraps(func)
def _retriable_wrapper(*args, **kwargs):
return retry(func, args=args, kwargs=kwargs, *retry_args,
**retry_kwargs)
return _retriable_wrapper
return _retriable_factory
@contextmanager
def retrying(func, *retry_args, **retry_kwargs):
"""
A context manager for wrapping functions with retry functionality.
Arguments:
func (callable): the function to wrap
other arguments as per `retry`
Returns:
A context manager that returns retriable(func) on __enter__
Example:
>>> count = 0
>>> def foo():
... global count
... count += 1
... print count
... if count < 3:
... raise ValueError("count too small")
... return "success!"
>>> with retrying(foo, sleeptime=0, jitter=0) as f:
... f()
1
2
3
'success!'
"""
yield retriable(*retry_args, **retry_kwargs)(func)