Skip to content

Commit 33a7120

Browse files
Add jitter to exponential retries and clamp retry count
1 parent 0086240 commit 33a7120

File tree

5 files changed

+181
-52
lines changed

5 files changed

+181
-52
lines changed

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ client = ClientBuilder(endpoint="https://api.example.com"
6565
,connection_timeout=10
6666
,headers=headers
6767
,retries=5
68-
,initial_delay=10)
68+
,initial_delay=10
69+
,jitter=0.5)
6970

7071

7172
# Get data from the API
@@ -79,10 +80,11 @@ print(df)
7980
```
8081

8182
## Important notes:
82-
* **Opcionals Parameters:** The params timeout, retry_strategy and headers are opcionals.
83+
* **Opcionals Parameters:** The params timeout, retry_strategy, headers and jitter are opcionals.
8384
* **Default Params Value:** By default the quantity of retries is 3 and the time between retries is 1 second, but you can define manually.
84-
* **Max Of Retries:** For security of API Server there is a limit for quantity of retries, actually this value is 5, this value is defined in lib constant. You can inform any value in RETRIES param, but the lib only will try 5x.
85+
* **Max Of Retries:** For security of API Server there is a limit for quantity of retries, actually this value is 5, this value is defined in lib constant. You can inform any value in RETRIES param, but the lib only will try 5x and will log a warning if a higher value is informed.
8586
* **Exponential Retry Strategy:** The increment of time between retries is time passed in **initial_delay** param * 2 * the retry_number, e.g with initial_delay=2
87+
* **Jitter Parameter:** When using the exponential strategy you can define a **jitter** value to add a random wait time between 0 and the informed jitter. This helps to avoid thundering herd effects.
8688

8789
RetryNumber | WaitingTime
8890
------------ | -----------

src/api_to_dataframe/controller/client_builder.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from api_to_dataframe.models.retainer import retry_strategies, Strategies
22
from api_to_dataframe.models.get_data import GetData
3+
from api_to_dataframe.utils import Constants
34
from api_to_dataframe.utils.logger import logger
45

56

@@ -12,6 +13,7 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument
1213
retries: int = 3,
1314
initial_delay: int = 1,
1415
connection_timeout: int = 1,
16+
jitter: float = 0.0,
1517
):
1618
"""
1719
Initializes the ClientBuilder object.
@@ -23,12 +25,14 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument
2325
retries (int): The number of times to retry a failed request. Defaults to 3.
2426
initial_delay (int): The delay between retries in seconds. Defaults to 1.
2527
connection_timeout (int): The timeout for the connection in seconds. Defaults to 1.
28+
jitter (float): Additional random jitter in seconds applied to exponential retries.
2629
2730
Raises:
2831
ValueError: If endpoint is an empty string.
2932
ValueError: If retries is not a non-negative integer.
3033
ValueError: If delay is not a non-negative integer.
3134
ValueError: If connection_timeout is not a non-negative integer.
35+
ValueError: If jitter is negative or not a numeric value.
3236
"""
3337

3438
if headers is None:
@@ -49,13 +53,25 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument
4953
error_msg = "connection_timeout must be a non-negative integer"
5054
logger.error(error_msg)
5155
raise ValueError
56+
if not isinstance(jitter, (int, float)) or jitter < 0:
57+
error_msg = "jitter must be a non-negative number"
58+
logger.error(error_msg)
59+
raise ValueError
5260

5361
self.endpoint = endpoint
5462
self.retry_strategy = retry_strategy
5563
self.connection_timeout = connection_timeout
5664
self.headers = headers
57-
self.retries = retries
65+
clamped_retries = min(retries, Constants.MAX_OF_RETRIES)
66+
if retries != clamped_retries:
67+
logger.warning(
68+
"Retry count %s exceeds global maximum of %s. Clamping to the allowed value.",
69+
retries,
70+
Constants.MAX_OF_RETRIES,
71+
)
72+
self.retries = clamped_retries
5873
self.delay = initial_delay
74+
self.jitter = float(jitter)
5975

6076
@retry_strategies
6177
def get_api_data(self):

src/api_to_dataframe/models/retainer.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import random
12
import time
23
from enum import Enum
34

@@ -15,11 +16,15 @@ class Strategies(Enum):
1516
def retry_strategies(func):
1617
def wrapper(*args, **kwargs): # pylint: disable=inconsistent-return-statements
1718
retry_number = 0
18-
while retry_number < args[0].retries:
19+
max_allowed_retries = min(args[0].retries, Constants.MAX_OF_RETRIES)
20+
while retry_number < max_allowed_retries:
1921
try:
2022
if retry_number > 0:
2123
logger.info(
22-
f"Trying for the {retry_number} of {Constants.MAX_OF_RETRIES} retries. Using {args[0].retry_strategy}"
24+
"Trying for the %s of %s retries. Using %s",
25+
retry_number,
26+
max_allowed_retries,
27+
args[0].retry_strategy,
2328
)
2429
return func(*args, **kwargs)
2530
except RequestException as e:
@@ -30,10 +35,13 @@ def wrapper(*args, **kwargs): # pylint: disable=inconsistent-return-statements
3035
if args[0].retry_strategy == Strategies.LINEAR_RETRY_STRATEGY:
3136
time.sleep(args[0].delay)
3237
elif args[0].retry_strategy == Strategies.EXPONENTIAL_RETRY_STRATEGY:
33-
time.sleep(args[0].delay * retry_number)
38+
jitter = 0.0
39+
if getattr(args[0], "jitter", 0) > 0:
40+
jitter = random.uniform(0, args[0].jitter)
41+
time.sleep(args[0].delay * retry_number + jitter)
3442

35-
if retry_number in (args[0].retries, Constants.MAX_OF_RETRIES):
36-
logger.error(f"Failed after {retry_number} retries")
43+
if retry_number >= max_allowed_retries:
44+
logger.error("Failed after %s retries", retry_number)
3745
raise e
3846

3947
return wrapper

tests/test_controller_client_builder.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,42 @@
77

88
@pytest.fixture()
99
def client_setup():
10-
new_client = ClientBuilder(
11-
endpoint="https://economia.awesomeapi.com.br/last/USD-BRL"
12-
)
13-
return new_client
10+
"""Create a client with a mocked API endpoint."""
11+
12+
endpoint = "https://economia.awesomeapi.com.br/last/USD-BRL"
13+
mocked_payload = {"USDBRL": {"code": "USD", "codein": "BRL", "bid": "5.0"}}
14+
responses_mock = responses.RequestsMock(assert_all_requests_are_fired=False)
15+
responses_mock.start()
16+
responses_mock.add(responses.GET, endpoint, json=mocked_payload, status=200)
17+
18+
client = ClientBuilder(endpoint=endpoint)
19+
yield client
20+
21+
responses_mock.stop()
22+
responses_mock.reset()
1423

1524

1625
@pytest.fixture()
1726
def response_setup():
18-
new_client = ClientBuilder(
19-
endpoint="https://economia.awesomeapi.com.br/last/USD-BRL"
20-
)
21-
return new_client.get_api_data()
27+
"""Provide a mocked API response as a dictionary."""
28+
29+
endpoint = "https://economia.awesomeapi.com.br/last/USD-BRL"
30+
mocked_payload = {"USDBRL": {"code": "USD", "codein": "BRL", "bid": "5.0"}}
31+
responses_mock = responses.RequestsMock(assert_all_requests_are_fired=False)
32+
responses_mock.start()
33+
responses_mock.add(responses.GET, endpoint, json=mocked_payload, status=200)
34+
35+
new_client = ClientBuilder(endpoint=endpoint)
36+
response = new_client.get_api_data()
37+
38+
responses_mock.stop()
39+
responses_mock.reset()
40+
41+
return response
2242

2343

2444
def test_constructor_raises():
45+
"""Ensure constructor validates required arguments."""
2546
with pytest.raises(ValueError):
2647
ClientBuilder(endpoint="")
2748

@@ -59,6 +80,7 @@ def test_constructor_raises():
5980

6081

6182
def test_constructor_with_param(client_setup): # pylint: disable=redefined-outer-name
83+
"""Verify constructor assigns provided endpoint."""
6284
expected_result = "https://economia.awesomeapi.com.br/last/USD-BRL"
6385
new_client = client_setup
6486
assert new_client.endpoint == expected_result
@@ -88,12 +110,14 @@ def test_constructor_with_retry_strategy():
88110

89111

90112
def test_response_to_json(client_setup): # pylint: disable=redefined-outer-name
113+
"""Validate the client converts the mocked response to a dict."""
91114
new_client = client_setup
92115
response = new_client.get_api_data() # pylint: disable=protected-access
93116
assert isinstance(response, dict)
94117

95118

96119
def test_to_dataframe(response_setup): # pylint: disable=redefined-outer-name
120+
"""Confirm the API response can be converted into a DataFrame."""
97121
df = ClientBuilder.api_to_dataframe(response_setup)
98122
assert isinstance(df, pd.DataFrame)
99123

tests/test_models_retainer.py

Lines changed: 114 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,147 @@
1-
import time
1+
import logging
22
import requests
33
import pytest
44

55
from api_to_dataframe import ClientBuilder, RetryStrategies
6+
from api_to_dataframe.utils import Constants
67

7-
# from api_to_dataframe.utils import Constants
88

9+
def _raise_request_exception(*args, **kwargs):
10+
raise requests.exceptions.RequestException("boom")
11+
12+
13+
def test_linear_strategy(monkeypatch):
14+
"""Ensure the linear strategy sleeps for a constant interval between retries."""
15+
16+
monkeypatch.setattr(
17+
"src.api_to_dataframe.models.get_data.requests.get",
18+
_raise_request_exception,
19+
)
20+
sleep_calls = []
21+
22+
def fake_sleep(delay):
23+
sleep_calls.append(delay)
24+
25+
monkeypatch.setattr("src.api_to_dataframe.models.retainer.time.sleep", fake_sleep)
926

10-
def test_linear_strategy():
11-
endpoint = "https://api-to-dataframe/"
12-
max_retries = 2
1327
client = ClientBuilder(
14-
endpoint=endpoint,
28+
endpoint="https://api-to-dataframe/",
1529
retry_strategy=RetryStrategies.LINEAR_RETRY_STRATEGY,
16-
retries=max_retries,
30+
retries=2,
1731
initial_delay=1,
1832
connection_timeout=1,
1933
)
2034

21-
retry_number = 0
35+
with pytest.raises(requests.exceptions.RequestException):
36+
client.get_api_data()
37+
38+
assert sleep_calls == [1, 1]
39+
2240

23-
while retry_number < max_retries:
24-
start = time.time()
25-
try:
26-
client.get_api_data()
27-
except requests.exceptions.RequestException:
28-
end = time.time()
29-
assert end - start >= client.delay
30-
retry_number += 1
41+
def test_no_retry_strategy(monkeypatch):
42+
"""Validate that no retry strategy raises immediately without sleeping."""
3143

32-
assert retry_number == max_retries
44+
monkeypatch.setattr(
45+
"src.api_to_dataframe.models.get_data.requests.get",
46+
_raise_request_exception,
47+
)
48+
sleep_calls = []
49+
50+
def fake_sleep(delay):
51+
sleep_calls.append(delay)
3352

53+
monkeypatch.setattr("src.api_to_dataframe.models.retainer.time.sleep", fake_sleep)
3454

35-
def test_no_retry_strategy():
36-
endpoint = "https://api-to-dataframe/"
3755
client = ClientBuilder(
38-
endpoint=endpoint,
56+
endpoint="https://api-to-dataframe/",
3957
retry_strategy=RetryStrategies.NO_RETRY_STRATEGY,
4058
)
4159

4260
with pytest.raises(requests.exceptions.RequestException):
4361
client.get_api_data()
4462

63+
assert sleep_calls == []
64+
65+
66+
def test_exponential_strategy(monkeypatch):
67+
"""Ensure the exponential strategy multiplies the delay by the retry attempt."""
68+
69+
monkeypatch.setattr(
70+
"src.api_to_dataframe.models.get_data.requests.get",
71+
_raise_request_exception,
72+
)
73+
sleep_calls = []
74+
75+
def fake_sleep(delay):
76+
sleep_calls.append(delay)
77+
78+
monkeypatch.setattr("src.api_to_dataframe.models.retainer.time.sleep", fake_sleep)
4579

46-
def test_exponential_strategy():
47-
endpoint = "https://api-to-dataframe/"
48-
max_retries = 2
4980
client = ClientBuilder(
50-
endpoint=endpoint,
81+
endpoint="https://api-to-dataframe/",
5182
retry_strategy=RetryStrategies.EXPONENTIAL_RETRY_STRATEGY,
52-
retries=max_retries,
83+
retries=3,
5384
initial_delay=1,
5485
connection_timeout=1,
5586
)
5687

57-
retry_number = 0
88+
with pytest.raises(requests.exceptions.RequestException):
89+
client.get_api_data()
90+
91+
assert sleep_calls == [1, 2, 3]
92+
5893

59-
while retry_number < max_retries:
60-
start = time.time()
61-
try:
62-
client.get_api_data()
63-
except requests.exceptions.RequestException:
64-
end = time.time()
65-
assert end - start >= client.delay * retry_number
66-
retry_number += 1
94+
def test_global_retry_cap_applied(caplog):
95+
"""Check that the global retry cap is enforced and a warning is logged when clamped."""
96+
97+
caplog.set_level(logging.WARNING)
98+
99+
client = ClientBuilder(
100+
endpoint="https://api-to-dataframe/",
101+
retry_strategy=RetryStrategies.LINEAR_RETRY_STRATEGY,
102+
retries=Constants.MAX_OF_RETRIES + 2,
103+
initial_delay=1,
104+
connection_timeout=1,
105+
)
106+
107+
assert client.retries == Constants.MAX_OF_RETRIES
108+
assert "Clamping" in caplog.text
109+
110+
111+
def test_exponential_strategy_uses_jitter(monkeypatch):
112+
"""Verify that exponential retries add the configured jitter to the sleep duration."""
113+
114+
monkeypatch.setattr(
115+
"src.api_to_dataframe.models.get_data.requests.get",
116+
_raise_request_exception,
117+
)
118+
sleep_calls = []
119+
120+
def fake_sleep(delay):
121+
sleep_calls.append(delay)
122+
123+
monkeypatch.setattr("src.api_to_dataframe.models.retainer.time.sleep", fake_sleep)
124+
125+
jitter_values = [0.5, 0.25]
126+
127+
def fake_uniform(_, __):
128+
return jitter_values.pop(0)
129+
130+
monkeypatch.setattr(
131+
"src.api_to_dataframe.models.retainer.random.uniform",
132+
fake_uniform,
133+
)
134+
135+
client = ClientBuilder(
136+
endpoint="https://api-to-dataframe/",
137+
retry_strategy=RetryStrategies.EXPONENTIAL_RETRY_STRATEGY,
138+
retries=2,
139+
initial_delay=1,
140+
connection_timeout=1,
141+
jitter=1.0,
142+
)
143+
144+
with pytest.raises(requests.exceptions.RequestException):
145+
client.get_api_data()
67146

68-
assert retry_number == max_retries
147+
assert sleep_calls == [1.5, 2.25]

0 commit comments

Comments
 (0)