-
Notifications
You must be signed in to change notification settings - Fork 342
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Rewrite of cloud.py with a new licence (#488)
* cloud.py re-implementation Co-authored-by: Mark Chappell <[email protected]>
- Loading branch information
Showing
2 changed files
with
415 additions
and
167 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,221 +1,206 @@ | ||
# Copyright (c) 2021 Ansible Project | ||
# | ||
# (c) 2016 Allen Sanabria, <[email protected]> | ||
# This code is part of Ansible, but is an independent component. | ||
# This particular file snippet, and this file snippet only, is BSD licensed. | ||
# Modules you write using this snippet, which is embedded dynamically by Ansible | ||
# still belong to the author of the module, and may assign their own license | ||
# to the complete work. | ||
# | ||
# This file is part of Ansible | ||
# Redistribution and use in source and binary forms, with or without modification, | ||
# are permitted provided that the following conditions are met: | ||
# | ||
# Ansible is free software: you can redistribute it and/or modify | ||
# it under the terms of the GNU General Public License as published by | ||
# the Free Software Foundation, either version 3 of the License, or | ||
# (at your option) any later version. | ||
# * Redistributions of source code must retain the above copyright | ||
# notice, this list of conditions and the following disclaimer. | ||
# * Redistributions in binary form must reproduce the above copyright notice, | ||
# this list of conditions and the following disclaimer in the documentation | ||
# and/or other materials provided with the distribution. | ||
# | ||
# Ansible is distributed in the hope that it will be useful, | ||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
# GNU General Public License for more details. | ||
# | ||
# You should have received a copy of the GNU General Public License | ||
# along with Ansible. If not, see <http://www.gnu.org/licenses/>. | ||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND | ||
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED | ||
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. | ||
# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, | ||
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, | ||
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS | ||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT | ||
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE | ||
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
# | ||
|
||
from __future__ import (absolute_import, division, print_function) | ||
__metaclass__ = type | ||
|
||
""" | ||
This module adds shared support for generic cloud modules | ||
In order to use this module, include it as part of a custom | ||
module as shown below. | ||
from ansible.module_utils.cloud import CloudRetry | ||
The 'cloud' module provides the following common classes: | ||
* CloudRetry | ||
- The base class to be used by other cloud providers, in order to | ||
provide a backoff/retry decorator based on status codes. | ||
- Example using the AWSRetry class which inherits from CloudRetry. | ||
@AWSRetry.exponential_backoff(retries=10, delay=3) | ||
get_ec2_security_group_ids_from_names() | ||
@AWSRetry.jittered_backoff() | ||
get_ec2_security_group_ids_from_names() | ||
""" | ||
import random | ||
from functools import wraps | ||
import syslog | ||
import time | ||
import functools | ||
import random | ||
|
||
|
||
def _exponential_backoff(retries=10, delay=2, backoff=2, max_delay=60): | ||
""" Customizable exponential backoff strategy. | ||
class BackoffIterator: | ||
"""iterate sleep value based on the exponential or jitter back-off algorithm. | ||
Args: | ||
retries (int): Maximum number of times to retry a request. | ||
delay (float): Initial (base) delay. | ||
backoff (float): base of the exponent to use for exponential | ||
backoff. | ||
max_delay (int): Optional. If provided each delay generated is capped | ||
at this amount. Defaults to 60 seconds. | ||
Returns: | ||
Callable that returns a generator. This generator yields durations in | ||
seconds to be used as delays for an exponential backoff strategy. | ||
Usage: | ||
>>> backoff = _exponential_backoff() | ||
>>> backoff | ||
<function backoff_backoff at 0x7f0d939facf8> | ||
>>> list(backoff()) | ||
[2, 4, 8, 16, 32, 60, 60, 60, 60, 60] | ||
delay (int or float): initial delay. | ||
backoff (int or float): backoff multiplier e.g. value of 2 will double the delay each retry. | ||
max_delay (int or None): maximum amount of time to wait between retries. | ||
jitter (bool): if set to true, add jitter to the generate value. | ||
""" | ||
def backoff_gen(): | ||
for retry in range(0, retries): | ||
sleep = delay * backoff ** retry | ||
yield sleep if max_delay is None else min(sleep, max_delay) | ||
return backoff_gen | ||
|
||
|
||
def _full_jitter_backoff(retries=10, delay=3, max_delay=60, _random=random): | ||
""" Implements the "Full Jitter" backoff strategy described here | ||
https://www.awsarchitectureblog.com/2015/03/backoff.html | ||
Args: | ||
retries (int): Maximum number of times to retry a request. | ||
delay (float): Approximate number of seconds to sleep for the first | ||
retry. | ||
max_delay (int): The maximum number of seconds to sleep for any retry. | ||
_random (random.Random or None): Makes this generator testable by | ||
allowing developers to explicitly pass in the a seeded Random. | ||
Returns: | ||
Callable that returns a generator. This generator yields durations in | ||
seconds to be used as delays for a full jitter backoff strategy. | ||
Usage: | ||
>>> backoff = _full_jitter_backoff(retries=5) | ||
>>> backoff | ||
<function backoff_backoff at 0x7f0d939facf8> | ||
>>> list(backoff()) | ||
[3, 6, 5, 23, 38] | ||
>>> list(backoff()) | ||
[2, 1, 6, 6, 31] | ||
def __init__(self, delay, backoff, max_delay=None, jitter=False): | ||
self.delay = delay | ||
self.backoff = backoff | ||
self.max_delay = max_delay | ||
self.jitter = jitter | ||
|
||
def __iter__(self): | ||
self.current_delay = self.delay | ||
return self | ||
|
||
def __next__(self): | ||
return_value = self.current_delay if self.max_delay is None else min(self.current_delay, self.max_delay) | ||
if self.jitter: | ||
return_value = random.uniform(0.0, return_value) | ||
self.current_delay *= self.backoff | ||
return return_value | ||
|
||
|
||
def _retry_func(func, sleep_time_generator, retries, catch_extra_error_codes, found_f, status_code_from_except_f, base_class): | ||
counter = 0 | ||
for sleep_time in sleep_time_generator: | ||
try: | ||
return func() | ||
except Exception as exc: | ||
counter += 1 | ||
if counter == retries: | ||
raise | ||
if base_class and not isinstance(exc, base_class): | ||
raise | ||
status_code = status_code_from_except_f(exc) | ||
if found_f(status_code, catch_extra_error_codes): | ||
time.sleep(sleep_time) | ||
else: | ||
raise | ||
|
||
|
||
class CloudRetry: | ||
""" | ||
def backoff_gen(): | ||
for retry in range(0, retries): | ||
yield _random.randint(0, min(max_delay, delay * 2 ** retry)) | ||
return backoff_gen | ||
|
||
|
||
class CloudRetry(object): | ||
""" CloudRetry can be used by any cloud provider, in order to implement a | ||
backoff algorithm/retry effect based on Status Code from Exceptions. | ||
The base class to be used by other cloud providers to provide a backoff/retry decorator based on status codes. | ||
""" | ||
# This is the base class of the exception. | ||
# AWS Example botocore.exceptions.ClientError | ||
# NoneType can't be raised (it's not a subclass of Exception) so would never be caught by an except. | ||
|
||
base_class = type(None) | ||
|
||
@staticmethod | ||
def status_code_from_exception(error): | ||
""" Return the status code from the exception object | ||
""" | ||
Returns the Error 'code' from an exception. | ||
Args: | ||
error (object): The exception itself. | ||
error: The Exception from which the error code is to be extracted. | ||
error will be an instance of class.base_class. | ||
""" | ||
pass | ||
raise NotImplementedError() | ||
|
||
@staticmethod | ||
def found(response_code, catch_extra_error_codes=None): | ||
""" Return True if the Response Code to retry on was found. | ||
Args: | ||
response_code (str): This is the Response Code that is being matched against. | ||
""" | ||
pass | ||
def _is_iterable(): | ||
try: | ||
it = iter(catch_extra_error_codes) | ||
except TypeError: | ||
# not iterable | ||
return False | ||
else: | ||
# iterable | ||
return True | ||
return _is_iterable() and response_code in catch_extra_error_codes | ||
|
||
@classmethod | ||
def _backoff(cls, backoff_strategy, catch_extra_error_codes=None): | ||
""" Retry calling the Cloud decorated function using the provided | ||
backoff strategy. | ||
Args: | ||
backoff_strategy (callable): Callable that returns a generator. The | ||
generator should yield sleep times for each retry of the decorated | ||
function. | ||
""" | ||
def deco(f): | ||
@wraps(f) | ||
def retry_func(*args, **kwargs): | ||
for delay in backoff_strategy(): | ||
try: | ||
return f(*args, **kwargs) | ||
except Exception as e: | ||
if isinstance(e, cls.base_class): | ||
response_code = cls.status_code_from_exception(e) | ||
if cls.found(response_code, catch_extra_error_codes): | ||
msg = "{0}: Retrying in {1} seconds...".format(str(e), delay) | ||
syslog.syslog(syslog.LOG_INFO, msg) | ||
time.sleep(delay) | ||
else: | ||
# Return original exception if exception is not a ClientError | ||
raise e | ||
else: | ||
# Return original exception if exception is not a ClientError | ||
raise e | ||
return f(*args, **kwargs) | ||
|
||
return retry_func # true decorator | ||
|
||
return deco | ||
def base_decorator(cls, retries, found, status_code_from_exception, catch_extra_error_codes, sleep_time_generator): | ||
def retry_decorator(func): | ||
@functools.wraps(func) | ||
def _retry_wrapper(*args, **kwargs): | ||
partial_func = functools.partial(func, *args, **kwargs) | ||
return _retry_func( | ||
func=partial_func, | ||
sleep_time_generator=sleep_time_generator, | ||
retries=retries, | ||
catch_extra_error_codes=catch_extra_error_codes, | ||
found_f=found, | ||
status_code_from_except_f=status_code_from_exception, | ||
base_class=cls.base_class, | ||
) | ||
return _retry_wrapper | ||
return retry_decorator | ||
|
||
@classmethod | ||
def exponential_backoff(cls, retries=10, delay=3, backoff=2, max_delay=60, catch_extra_error_codes=None): | ||
""" | ||
Retry calling the Cloud decorated function using an exponential backoff. | ||
Kwargs: | ||
"""Wrap a callable with retry behavior. | ||
Args: | ||
retries (int): Number of times to retry a failed request before giving up | ||
default=10 | ||
delay (int or float): Initial delay between retries in seconds | ||
default=3 | ||
backoff (int or float): backoff multiplier e.g. value of 2 will | ||
double the delay each retry | ||
default=1.1 | ||
backoff (int or float): backoff multiplier e.g. value of 2 will double the delay each retry | ||
default=2 | ||
max_delay (int or None): maximum amount of time to wait between retries. | ||
default=60 | ||
catch_extra_error_codes: Additional error messages to catch, in addition to those which may be defined by a subclass of CloudRetry | ||
default=None | ||
Returns: | ||
Callable: A generator that calls the decorated function using an exponential backoff. | ||
""" | ||
return cls._backoff(_exponential_backoff( | ||
retries=retries, delay=delay, backoff=backoff, max_delay=max_delay), catch_extra_error_codes) | ||
sleep_time_generator = BackoffIterator(delay=delay, backoff=backoff, max_delay=max_delay) | ||
return cls.base_decorator( | ||
retries=retries, | ||
found=cls.found, | ||
status_code_from_exception=cls.status_code_from_exception, | ||
catch_extra_error_codes=catch_extra_error_codes, | ||
sleep_time_generator=sleep_time_generator, | ||
) | ||
|
||
@classmethod | ||
def jittered_backoff(cls, retries=10, delay=3, max_delay=60, catch_extra_error_codes=None): | ||
""" | ||
Retry calling the Cloud decorated function using a jittered backoff | ||
strategy. More on this strategy here: | ||
https://www.awsarchitectureblog.com/2015/03/backoff.html | ||
Kwargs: | ||
def jittered_backoff(cls, retries=10, delay=3, backoff=2.0, max_delay=60, catch_extra_error_codes=None): | ||
"""Wrap a callable with retry behavior. | ||
Args: | ||
retries (int): Number of times to retry a failed request before giving up | ||
default=10 | ||
delay (int): Initial delay between retries in seconds | ||
delay (int or float): Initial delay between retries in seconds | ||
default=3 | ||
max_delay (int): maximum amount of time to wait between retries. | ||
backoff (int or float): backoff multiplier e.g. value of 2 will double the delay each retry | ||
default=2.0 | ||
max_delay (int or None): maximum amount of time to wait between retries. | ||
default=60 | ||
catch_extra_error_codes: Additional error messages to catch, in addition to those which may be defined by a subclass of CloudRetry | ||
default=None | ||
Returns: | ||
Callable: A generator that calls the decorated function using using a jittered backoff strategy. | ||
""" | ||
return cls._backoff(_full_jitter_backoff( | ||
retries=retries, delay=delay, max_delay=max_delay), catch_extra_error_codes) | ||
sleep_time_generator = BackoffIterator(delay=delay, backoff=backoff, max_delay=max_delay, jitter=True) | ||
return cls.base_decorator( | ||
retries=retries, | ||
found=cls.found, | ||
status_code_from_exception=cls.status_code_from_exception, | ||
catch_extra_error_codes=catch_extra_error_codes, | ||
sleep_time_generator=sleep_time_generator, | ||
) | ||
|
||
@classmethod | ||
def backoff(cls, tries=10, delay=3, backoff=1.1, catch_extra_error_codes=None): | ||
""" | ||
Retry calling the Cloud decorated function using an exponential backoff. | ||
Compatibility for the original implementation of CloudRetry.backoff that | ||
did not provide configurable backoff strategies. Developers should use | ||
CloudRetry.exponential_backoff instead. | ||
Kwargs: | ||
tries (int): Number of times to try (not retry) before giving up | ||
Wrap a callable with retry behavior. | ||
Developers should use CloudRetry.exponential_backoff instead. | ||
This method has been deprecated and will be removed in release 4.0.0, consider using exponential_backoff method instead. | ||
Args: | ||
retries (int): Number of times to retry a failed request before giving up | ||
default=10 | ||
delay (int or float): Initial delay between retries in seconds | ||
default=3 | ||
backoff (int or float): backoff multiplier e.g. value of 2 will | ||
double the delay each retry | ||
backoff (int or float): backoff multiplier e.g. value of 2 will double the delay each retry | ||
default=1.1 | ||
catch_extra_error_codes: Additional error messages to catch, in addition to those which may be defined by a subclass of CloudRetry | ||
default=None | ||
Returns: | ||
Callable: A generator that calls the decorated function using an exponential backoff. | ||
""" | ||
return cls.exponential_backoff( | ||
retries=tries - 1, delay=delay, backoff=backoff, max_delay=None, catch_extra_error_codes=catch_extra_error_codes) | ||
retries=tries, | ||
delay=delay, | ||
backoff=backoff, | ||
max_delay=None, | ||
catch_extra_error_codes=catch_extra_error_codes, | ||
) |
Oops, something went wrong.