ansible-collection-hetzner-.../plugins/module_utils/client.py
Jonas L 19e586fa22
feat: use exponential backoff algorithm when polling actions (#524)
##### SUMMARY

Replace the constant poll interval of 1 second, with a truncated
exponential back off algorithm with jitter.

Below is a suite of poll interval (in seconds) generated by the new
algorithm:
```
1.49
2.14
5.46
6.51
6.57
5.57
5.98
7.13
6.59
7.10
5.54
5.03
6.56
5.96
6.72
7.21
7.05
5.31
5.60
6.33
6.82
5.42
6.08
6.60
TOTAL: 140.77
```
2024-07-04 15:07:05 +02:00

128 lines
3.9 KiB
Python

# Copyright: (c) 2023, Hetzner Cloud GmbH <info@hetzner-cloud.de>
from __future__ import annotations
from contextlib import contextmanager
from random import random
from ansible.module_utils.basic import missing_required_lib
from .vendor.hcloud import APIException, Client as ClientBase
HAS_REQUESTS = True
HAS_DATEUTIL = True
try:
import requests # pylint: disable=unused-import
except ImportError:
HAS_REQUESTS = False
try:
import dateutil # pylint: disable=unused-import
except ImportError:
HAS_DATEUTIL = False
class ClientException(Exception):
"""An error related to the client occurred."""
def client_check_required_lib():
if not HAS_REQUESTS:
raise ClientException(missing_required_lib("requests"))
if not HAS_DATEUTIL:
raise ClientException(missing_required_lib("python-dateutil"))
def _client_resource_not_found(resource: str, param: str | int):
return ClientException(f"resource ({resource.rstrip('s')}) does not exist: {param}")
def client_get_by_name_or_id(client: Client, resource: str, param: str | int):
"""
Get a resource by name, and if not found by its ID.
:param client: Client to use to make the call
:param resource: Name of the resource client that implements both `get_by_name` and `get_by_id` methods
:param param: Name or ID of the resource to query
"""
resource_client = getattr(client, resource)
result = resource_client.get_by_name(param)
if result is not None:
return result
# If the param is not a valid ID, prevent an unnecessary call to the API.
try:
int(param)
except ValueError as exception:
raise _client_resource_not_found(resource, param) from exception
try:
return resource_client.get_by_id(param)
except APIException as exception:
if exception.code == "not_found":
raise _client_resource_not_found(resource, param) from exception
raise exception
if HAS_REQUESTS:
class CachedSession(requests.Session):
cache: dict[str, requests.Response]
def __init__(self) -> None:
super().__init__()
self.cache = {}
def send(self, request: requests.PreparedRequest, **kwargs) -> requests.Response: # type: ignore[no-untyped-def]
"""
Send a given PreparedRequest.
"""
if request.method != "GET" or request.url is None:
return super().send(request, **kwargs)
if request.url in self.cache:
return self.cache[request.url]
response = super().send(request, **kwargs)
if response.ok:
self.cache[request.url] = response
return response
class Client(ClientBase):
@contextmanager
def cached_session(self):
"""
Swap the client session during the scope of the context. The session will cache
all GET requests.
Cached response will not expire, therefore the cached client must not be used
for long living scopes.
"""
self._requests_session = CachedSession()
try:
yield
finally:
self._requests_session = requests.Session()
def exponential_backoff_poll_interval(*, base: float, multiplier: int, cap: float, jitter: float):
"""
Return a poll interval function, implementing a truncated exponential backoff with jitter.
:param base: Base for the exponential backoff algorithm.
:param multiplier: Multiplier for the exponential backoff algorithm.
:param cap: Value at which the interval is truncated.
:param jitter: Proportion of the interval to add as random jitter.
"""
def func(retries: int) -> float:
interval = base * multiplier**retries # Exponential backoff
interval = min(cap, interval) # Cap backoff
interval += random() * interval * jitter # Add jitter
return interval
return func