Skip to content

Commit 7797f8d

Browse files
Add unit tests for the backoff module
Signed-off-by: camille-bouvy-frequenz <[email protected]>
1 parent 22e8dfa commit 7797f8d

File tree

1 file changed

+218
-0
lines changed

1 file changed

+218
-0
lines changed

tests/test_backoff.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the backoff strategy to handle non-successful gRPC connection attempts."""
5+
6+
import asyncio
7+
from unittest.mock import AsyncMock
8+
9+
import pytest
10+
11+
from frequenz.client.electricity_trading._backoff import Backoff
12+
13+
14+
@pytest.mark.asyncio
15+
async def test_successful_execution() -> None:
16+
"""Test that the backoff executes a successful call."""
17+
backoff = Backoff(
18+
initial_timeout=1.0,
19+
max_timeout=10.0,
20+
timeout_growth_factor=2.0,
21+
max_retries=3,
22+
reset_interval=600,
23+
)
24+
mock_call = AsyncMock(return_value="success")
25+
26+
result = await backoff.execute_with_backoff(mock_call)
27+
assert result == "success"
28+
mock_call.assert_awaited_once()
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_success_after_one_timeout() -> None:
33+
"""Test that the backoff retries once and then succeeds."""
34+
backoff = Backoff(
35+
initial_timeout=1.0,
36+
max_timeout=10.0,
37+
timeout_growth_factor=2.0,
38+
max_retries=3,
39+
reset_interval=600,
40+
)
41+
mock_call = AsyncMock(side_effect=[asyncio.TimeoutError, "success"])
42+
43+
result = await backoff.execute_with_backoff(mock_call)
44+
assert result == "success"
45+
assert mock_call.await_count == 2
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_success_on_max_retries() -> None:
50+
"""Test that the backoff retries the maximum number of times and succeeds on the last one."""
51+
backoff = Backoff(
52+
initial_timeout=1.0,
53+
max_timeout=10.0,
54+
timeout_growth_factor=2.0,
55+
max_retries=3,
56+
reset_interval=600,
57+
)
58+
mock_call = AsyncMock(side_effect=[asyncio.TimeoutError] * 3 + ["success"])
59+
60+
result = await backoff.execute_with_backoff(mock_call)
61+
assert result == "success"
62+
assert mock_call.await_count == 4 # Initial call + 3 retries
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_failure_after_max_retries() -> None:
67+
"""Test that the backoff retries the maximum number of times and fails."""
68+
backoff = Backoff(
69+
initial_timeout=1.0,
70+
max_timeout=10.0,
71+
timeout_growth_factor=2.0,
72+
max_retries=3,
73+
reset_interval=600,
74+
)
75+
mock_call = AsyncMock(side_effect=asyncio.TimeoutError)
76+
77+
with pytest.raises(asyncio.TimeoutError):
78+
await backoff.execute_with_backoff(mock_call)
79+
80+
assert mock_call.await_count == 4 # Initial call + 3 retries
81+
82+
83+
@pytest.mark.asyncio
84+
async def test_timeout_increase() -> None:
85+
"""Test that the timeout increases with retries."""
86+
backoff = Backoff(
87+
initial_timeout=1.0,
88+
max_timeout=10.0,
89+
timeout_growth_factor=2.0,
90+
max_retries=3,
91+
reset_interval=600,
92+
)
93+
mock_call = AsyncMock(side_effect=asyncio.TimeoutError)
94+
95+
with pytest.raises(asyncio.TimeoutError):
96+
await backoff.execute_with_backoff(mock_call)
97+
98+
# Initial timeout * (growth factor ** max_retries)
99+
assert backoff._timeout == 8.0 # pylint: disable=protected-access
100+
101+
102+
@pytest.mark.asyncio
103+
async def test_invalid_growth_factor() -> None:
104+
"""Test that an invalid growth factor raises a ValueError."""
105+
with pytest.raises(ValueError):
106+
Backoff(
107+
initial_timeout=1.0,
108+
max_timeout=10.0,
109+
timeout_growth_factor=0.5, # Invalid
110+
max_retries=3,
111+
reset_interval=600,
112+
)
113+
114+
115+
@pytest.mark.asyncio
116+
async def test_timeout_reset() -> None:
117+
"""Test that the timeout resets after successful execution."""
118+
backoff = Backoff(
119+
initial_timeout=1.0,
120+
max_timeout=10.0,
121+
timeout_growth_factor=2.0,
122+
max_retries=3,
123+
reset_interval=1, # Short reset interval for testing
124+
)
125+
126+
# First make it fail fully so that the timeout is increased
127+
mock_call = AsyncMock(side_effect=asyncio.TimeoutError)
128+
with pytest.raises(asyncio.TimeoutError):
129+
await backoff.execute_with_backoff(mock_call)
130+
131+
# Wait until the reset interval has passed
132+
await asyncio.sleep(1.5)
133+
134+
# Make a second attempt that succeeds
135+
mock_call = AsyncMock(return_value="success")
136+
await backoff.execute_with_backoff(mock_call)
137+
138+
# Check that the timeout was resetted
139+
assert (
140+
backoff._timeout == backoff._initial_timeout # pylint: disable=protected-access
141+
)
142+
143+
144+
@pytest.mark.asyncio
145+
async def test_zero_reset_interval() -> None:
146+
"""Test that the timeout does not reset if the reset interval is 0."""
147+
backoff = Backoff(
148+
initial_timeout=1.0,
149+
max_timeout=10.0,
150+
timeout_growth_factor=2.0,
151+
max_retries=3,
152+
reset_interval=0,
153+
)
154+
155+
# First make it fail fully so that the timeout is increased
156+
mock_call = AsyncMock(side_effect=asyncio.TimeoutError)
157+
with pytest.raises(asyncio.TimeoutError):
158+
await backoff.execute_with_backoff(mock_call)
159+
160+
# Make a second attempt that succeeds
161+
mock_call = AsyncMock(return_value="success")
162+
await backoff.execute_with_backoff(mock_call)
163+
164+
# Check that the timeout did not reset
165+
assert (
166+
backoff._timeout > backoff._initial_timeout # pylint: disable=protected-access
167+
)
168+
169+
170+
def test_negative_reset_interval() -> None:
171+
"""Test that a negative reset interval raises a ValueError."""
172+
with pytest.raises(ValueError):
173+
Backoff(
174+
initial_timeout=1.0,
175+
max_timeout=10.0,
176+
timeout_growth_factor=2.0,
177+
max_retries=3,
178+
reset_interval=-1, # Invalid negative value
179+
)
180+
181+
182+
@pytest.mark.asyncio
183+
async def test_max_timeout() -> None:
184+
"""Test that the timeout stops increasing after reaching max_timeout."""
185+
backoff = Backoff(
186+
initial_timeout=1.0,
187+
max_timeout=8.0, # Lower max_timeout for testing
188+
timeout_growth_factor=2.0,
189+
max_retries=5,
190+
reset_interval=600,
191+
)
192+
193+
# Simulate timeout error on every call
194+
mock_call = AsyncMock(side_effect=asyncio.TimeoutError)
195+
with pytest.raises(asyncio.TimeoutError):
196+
await backoff.execute_with_backoff(mock_call)
197+
198+
assert backoff._timeout == backoff._max_timeout # pylint: disable=protected-access
199+
200+
201+
@pytest.mark.asyncio
202+
async def test_initial_timeout_equals_max_timeout() -> None:
203+
"""Test that backoff works when initial_timeout equals max_timeout."""
204+
backoff = Backoff(
205+
initial_timeout=5.0, # Same as max_timeout
206+
max_timeout=5.0,
207+
timeout_growth_factor=2.0,
208+
max_retries=3,
209+
reset_interval=600,
210+
)
211+
212+
# Simulate timeout error on every call
213+
mock_call = AsyncMock(side_effect=asyncio.TimeoutError)
214+
with pytest.raises(asyncio.TimeoutError):
215+
await backoff.execute_with_backoff(mock_call)
216+
217+
assert backoff._timeout == 5.0 # pylint: disable=protected-access
218+
assert mock_call.await_count == 4 # Initial call + 3 retries

0 commit comments

Comments
 (0)