Skip to content

Commit fe7ddf6

Browse files
authored
feat: add exponential and constant backoff function (#416)
- Implement the same backoff function as in the hcloud-go libary - Preparation work to change the retry backoff function to use an exponential backoff interval. - Rename PollIntervalFunction to BackoffFunction, as it is not only used for polling.
1 parent 4bca300 commit fe7ddf6

3 files changed

Lines changed: 77 additions & 5 deletions

File tree

hcloud/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from __future__ import annotations
22

3-
from ._client import Client as Client # noqa pylint: disable=C0414
3+
from ._client import ( # noqa pylint: disable=C0414
4+
Client as Client,
5+
constant_backoff_function as constant_backoff_function,
6+
exponential_backoff_function as exponential_backoff_function,
7+
)
48
from ._exceptions import ( # noqa pylint: disable=C0414
59
APIException as APIException,
610
HCloudException as HCloudException,

hcloud/_client.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import time
4+
from random import uniform
45
from typing import Protocol
56

67
import requests
@@ -26,7 +27,7 @@
2627
from .volumes import VolumesClient
2728

2829

29-
class PollIntervalFunction(Protocol):
30+
class BackoffFunction(Protocol):
3031
def __call__(self, retries: int) -> float:
3132
"""
3233
Return a interval in seconds to wait between each API call.
@@ -35,6 +36,47 @@ def __call__(self, retries: int) -> float:
3536
"""
3637

3738

39+
def constant_backoff_function(interval: float) -> BackoffFunction:
40+
"""
41+
Return a backoff function, implementing a constant backoff.
42+
43+
:param interval: Constant interval to return.
44+
"""
45+
46+
# pylint: disable=unused-argument
47+
def func(retries: int) -> float:
48+
return interval
49+
50+
return func
51+
52+
53+
def exponential_backoff_function(
54+
*,
55+
base: float,
56+
multiplier: int,
57+
cap: float,
58+
jitter: bool = False,
59+
) -> BackoffFunction:
60+
"""
61+
Return a backoff function, implementing a truncated exponential backoff with
62+
optional full jitter.
63+
64+
:param base: Base for the exponential backoff algorithm.
65+
:param multiplier: Multiplier for the exponential backoff algorithm.
66+
:param cap: Value at which the interval is truncated.
67+
:param jitter: Whether to add jitter.
68+
"""
69+
70+
def func(retries: int) -> float:
71+
interval = base * multiplier**retries # Exponential backoff
72+
interval = min(cap, interval) # Cap backoff
73+
if jitter:
74+
interval = uniform(base, interval) # Add jitter
75+
return interval
76+
77+
return func
78+
79+
3880
class Client:
3981
"""Base Client for accessing the Hetzner Cloud API"""
4082

@@ -48,7 +90,7 @@ def __init__(
4890
api_endpoint: str = "https://api.hetzner.cloud/v1",
4991
application_name: str | None = None,
5092
application_version: str | None = None,
51-
poll_interval: int | float | PollIntervalFunction = 1.0,
93+
poll_interval: int | float | BackoffFunction = 1.0,
5294
poll_max_retries: int = 120,
5395
timeout: float | tuple[float, float] | None = None,
5496
):
@@ -73,7 +115,7 @@ def __init__(
73115
self._requests_timeout = timeout
74116

75117
if isinstance(poll_interval, (int, float)):
76-
self._poll_interval_func = lambda _: poll_interval # Constant poll interval
118+
self._poll_interval_func = constant_backoff_function(poll_interval)
77119
else:
78120
self._poll_interval_func = poll_interval
79121
self._poll_max_retries = poll_max_retries

tests/unit/test_client.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
import pytest
77
import requests
88

9-
from hcloud import APIException, Client
9+
from hcloud import (
10+
APIException,
11+
Client,
12+
constant_backoff_function,
13+
exponential_backoff_function,
14+
)
1015

1116

1217
class TestHetznerClient:
@@ -202,3 +207,24 @@ def test_request_limit_then_success(self, client, rate_limit_response):
202207
"POST", "http://url.com", params={"argument": "value"}, timeout=2
203208
)
204209
assert client._requests_session.request.call_count == 2
210+
211+
212+
def test_constant_backoff_function():
213+
backoff = constant_backoff_function(interval=1.0)
214+
max_retries = 5
215+
216+
for i in range(max_retries):
217+
assert backoff(i) == 1.0
218+
219+
220+
def test_exponential_backoff_function():
221+
backoff = exponential_backoff_function(
222+
base=1.0,
223+
multiplier=2,
224+
cap=60.0,
225+
)
226+
max_retries = 5
227+
228+
results = [backoff(i) for i in range(max_retries)]
229+
assert sum(results) == 31.0
230+
assert results == [1.0, 2.0, 4.0, 8.0, 16.0]

0 commit comments

Comments
 (0)