Skip to content

Commit ec6da3c

Browse files
tests for http retries
Signed-off-by: Elena Kolevska <elena@kolevska.com>
1 parent 46abbf8 commit ec6da3c

File tree

7 files changed

+287
-78
lines changed

7 files changed

+287
-78
lines changed

dapr/clients/http/client.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
DAPR_USER_AGENT,
2525
CONTENT_TYPE_HEADER,
2626
)
27-
from dapr.clients.health import DaprHealth
2827
from dapr.clients.retry import RetryPolicy
2928

3029
if TYPE_CHECKING:
@@ -105,33 +104,43 @@ async def retry_call(self, session, req):
105104
# If max_retries is 0, we don't retry
106105
if self.retry_policy.max_attempts == 0:
107106
return await session.request(
108-
method=req["method"],
109-
url=req["url"],
110-
data=req["data"],
111-
headers=req["headers"],
112-
ssl=req["sslcontext"],
113-
params=req["params"],
107+
method=req['method'],
108+
url=req['url'],
109+
data=req['data'],
110+
headers=req['headers'],
111+
ssl=req['sslcontext'],
112+
params=req['params'],
114113
)
115114

116115
attempt = 0
117116
while self.retry_policy.max_attempts == -1 or attempt < self.retry_policy.max_attempts: # type: ignore
118-
print(f'Trying RPC call, attempt {attempt + 1}')
119-
r = await session.request(method=req["method"], url=req["url"], data=req["data"],
120-
headers=req["headers"], ssl=req["sslcontext"], params=req["params"], )
117+
print(f'Request attempt {attempt + 1}')
118+
r = await session.request(
119+
method=req['method'],
120+
url=req['url'],
121+
data=req['data'],
122+
headers=req['headers'],
123+
ssl=req['sslcontext'],
124+
params=req['params'],
125+
)
121126

122-
if r.status not in self.retry_policy.retryable_http_status_codes:
123-
return r
127+
if r.status not in self.retry_policy.retryable_http_status_codes:
128+
return r
124129

125-
if self.retry_policy.max_attempts != -1 and attempt == self.retry_policy.max_attempts - 1: # type: ignore
126-
return r
130+
if (
131+
self.retry_policy.max_attempts != -1
132+
and attempt == self.retry_policy.max_attempts - 1 # type: ignore
133+
): # type: ignore
134+
return r
127135

128-
sleep_time = min(self.retry_policy.max_backoff,
129-
self.retry_policy.initial_backoff * (self.retry_policy.backoff_multiplier ** attempt), )
136+
sleep_time = min(
137+
self.retry_policy.max_backoff,
138+
self.retry_policy.initial_backoff * (self.retry_policy.backoff_multiplier**attempt),
139+
)
130140

131-
print(f'Sleeping for {sleep_time} seconds before retrying RPC call')
132-
await asyncio.sleep(sleep_time)
133-
attempt += 1
134-
raise Exception(f'Request failed after {attempt} retries')
141+
print(f'Sleeping for {sleep_time} seconds before retrying call')
142+
await asyncio.sleep(sleep_time)
143+
attempt += 1
135144

136145
async def convert_to_error(self, response: aiohttp.ClientResponse) -> DaprInternalError:
137146
error_info = None

dapr/clients/retry.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def __init__(
4545
StatusCode.DEADLINE_EXCEEDED,
4646
],
4747
):
48-
if max_attempts < -1:
48+
if max_attempts < -1: # type: ignore
4949
raise ValueError('max_attempts must be greater than or equal to -1')
5050
self.max_attempts = max_attempts
5151

@@ -62,11 +62,11 @@ def __init__(
6262
self.backoff_multiplier = backoff_multiplier
6363

6464
if len(retryable_http_status_codes) == 0:
65-
raise ValueError('retryable_http_status_codes can\'t be empty')
65+
raise ValueError("retryable_http_status_codes can't be empty")
6666
self.retryable_http_status_codes = retryable_http_status_codes
6767

6868
if len(retryable_grpc_status_codes) == 0:
69-
raise ValueError('retryable_http_status_codes can\'t be empty')
69+
raise ValueError("retryable_http_status_codes can't be empty")
7070
self.retryable_grpc_status_codes = retryable_grpc_status_codes
7171

7272

daprdocs/content/en/python-sdk-docs/python-client.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ set it in the environment and the client will use it automatically.
7575
You can read more about Dapr API token authentication [here](https://docs.dapr.io/operations/security/api-token/).
7676

7777
##### Health timeout
78-
On client initialisation, a health check is performed against the Dapr sidecar (`/healthz/outboud`).
78+
On client initialisation, a health check is performed against the Dapr sidecar (`/healthz/outbound`).
7979
The client will wait for the sidecar to be up and running before proceeding.
8080

8181
The default timeout is 60 seconds, but it can be overridden by setting the `DAPR_HEALTH_TIMEOUT`
Lines changed: 3 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -21,53 +21,6 @@
2121
from dapr.clients.retry import RetryPolicy, run_rpc_with_retry
2222

2323

24-
class RetryPolicyTests(unittest.TestCase):
25-
def test_init_success_default(self):
26-
policy = RetryPolicy()
27-
28-
self.assertEqual(0, policy.max_attempts)
29-
self.assertEqual(1, policy.initial_backoff)
30-
self.assertEqual(20, policy.max_backoff)
31-
self.assertEqual(1.5, policy.backoff_multiplier)
32-
self.assertEqual([408, 429, 500, 502, 503, 504], policy.retryable_http_status_codes)
33-
self.assertEqual([StatusCode.UNAVAILABLE, StatusCode.DEADLINE_EXCEEDED], policy.retryable_grpc_status_codes)
34-
35-
def test_init_success(self):
36-
policy = RetryPolicy(
37-
max_attempts=3,
38-
initial_backoff=2,
39-
max_backoff=10,
40-
backoff_multiplier=2,
41-
retryable_grpc_status_codes=[StatusCode.UNAVAILABLE],
42-
retryable_http_status_codes=[408, 429]
43-
)
44-
self.assertEqual(3, policy.max_attempts)
45-
self.assertEqual(2, policy.initial_backoff)
46-
self.assertEqual(10, policy.max_backoff)
47-
self.assertEqual(2, policy.backoff_multiplier)
48-
self.assertEqual([StatusCode.UNAVAILABLE], policy.retryable_grpc_status_codes)
49-
self.assertEqual([408, 429], policy.retryable_http_status_codes)
50-
51-
def test_init_with_errors(self):
52-
with self.assertRaises(ValueError):
53-
RetryPolicy(max_attempts=-2)
54-
55-
with self.assertRaises(ValueError):
56-
RetryPolicy(initial_backoff=0)
57-
58-
with self.assertRaises(ValueError):
59-
RetryPolicy(max_backoff=0)
60-
61-
with self.assertRaises(ValueError):
62-
RetryPolicy(backoff_multiplier=0)
63-
64-
with self.assertRaises(ValueError):
65-
RetryPolicy(retryable_http_status_codes=[])
66-
67-
with self.assertRaises(ValueError):
68-
RetryPolicy(retryable_grpc_status_codes=[])
69-
70-
7124
class RetriesTest(unittest.TestCase):
7225
def test_run_rpc_with_retry_success(self):
7326
mock_func = Mock(return_value='success')
@@ -111,8 +64,9 @@ def test_run_rpc_with_retry_fail_with_another_status_code(self):
11164
mock_func = MagicMock(side_effect=mock_error)
11265

11366
with self.assertRaises(RpcError):
114-
policy = RetryPolicy(max_attempts=3,
115-
retryable_grpc_status_codes=[StatusCode.UNAVAILABLE])
67+
policy = RetryPolicy(
68+
max_attempts=3, retryable_grpc_status_codes=[StatusCode.UNAVAILABLE]
69+
)
11670
run_rpc_with_retry(policy, mock_func)
11771

11872
mock_func.assert_called_once()
Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from dapr.clients.retry import RetryPolicy, async_run_rpc_with_retry
2222

2323

24-
class AsyncRetriesTests(unittest.IsolatedAsyncioTestCase):
24+
class RetryPolicyGrpcAsyncTests(unittest.IsolatedAsyncioTestCase):
2525
async def test_run_rpc_with_retry_success(self):
2626
mock_func = AsyncMock(return_value='success')
2727

@@ -40,7 +40,7 @@ async def test_run_rpc_with_retry_no_retry(self):
4040
await async_run_rpc_with_retry(RetryPolicy(max_attempts=0), mock_func)
4141
mock_func.assert_awaited_once()
4242

43-
@patch('asyncio.sleep', return_value=None) # To speed up tests
43+
@patch('asyncio.sleep', return_value=None)
4444
async def test_run_rpc_with_retry_fail(self, mock_sleep):
4545
mock_error = RpcError()
4646
mock_error.code = MagicMock(return_value=StatusCode.UNAVAILABLE)
@@ -64,12 +64,14 @@ async def test_run_rpc_with_retry_fail_with_another_status_code(self):
6464
mock_func = AsyncMock(side_effect=mock_error)
6565

6666
with self.assertRaises(RpcError):
67-
policy = RetryPolicy(max_attempts=3, retryable_grpc_status_codes=[StatusCode.UNAVAILABLE])
67+
policy = RetryPolicy(
68+
max_attempts=3, retryable_grpc_status_codes=[StatusCode.UNAVAILABLE]
69+
)
6870
await async_run_rpc_with_retry(policy, mock_func)
6971

7072
mock_func.assert_awaited_once()
7173

72-
@patch('asyncio.sleep', return_value=None) # To speed up tests
74+
@patch('asyncio.sleep', return_value=None)
7375
async def test_run_rpc_with_retry_fail_with_max_backoff(self, mock_sleep):
7476
mock_error = RpcError()
7577
mock_error.code = MagicMock(return_value=StatusCode.UNAVAILABLE)
@@ -91,7 +93,7 @@ async def test_run_rpc_with_retry_fail_with_max_backoff(self, mock_sleep):
9193
]
9294
mock_sleep.assert_has_calls(expected_sleep_calls, any_order=False)
9395

94-
@patch('asyncio.sleep', return_value=None) # To speed up tests
96+
@patch('asyncio.sleep', return_value=None)
9597
async def test_run_rpc_with_infinite_retries(self, mock_sleep):
9698
# Testing a function that's supposed to run forever is tricky, so we'll simulate it
9799
# Instead of a fixed side effect, we'll create a function that's supposed to

tests/clients/test_retries_http.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
Copyright 2024 The Dapr Authors
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
"""
15+
import unittest
16+
from unittest import mock
17+
from unittest.mock import patch, MagicMock, AsyncMock
18+
19+
from dapr.clients.http.client import DaprHttpClient
20+
from dapr.clients.retry import RetryPolicy
21+
from dapr.serializers import DefaultJSONSerializer
22+
23+
24+
class RetryPolicyHttpTests(unittest.IsolatedAsyncioTestCase):
25+
async def asyncSetUp(self):
26+
# Setup your test environment and mocks here
27+
self.session = MagicMock()
28+
self.session.request = AsyncMock()
29+
30+
self.serializer = (DefaultJSONSerializer(),)
31+
self.client = DaprHttpClient(message_serializer=self.serializer)
32+
33+
# Example request
34+
self.req = {
35+
'method': 'GET',
36+
'url': 'http://example.com',
37+
'data': None,
38+
'headers': None,
39+
'sslcontext': None,
40+
'params': None,
41+
}
42+
43+
async def test_run_with_success(self):
44+
# Mock the request to succeed on the first try
45+
self.session.request.return_value.status = 200
46+
47+
response = await self.client.retry_call(self.session, self.req)
48+
49+
self.session.request.assert_called_once()
50+
self.assertEqual(200, response.status)
51+
52+
async def test_success_run_with_no_retry(self):
53+
self.session.request.return_value.status = 200
54+
55+
client = DaprHttpClient(
56+
message_serializer=self.serializer, retry_policy=RetryPolicy(max_attempts=0)
57+
)
58+
response = await client.retry_call(self.session, self.req)
59+
60+
self.session.request.assert_called_once()
61+
self.assertEqual(200, response.status)
62+
63+
async def test_fail_run_with_no_retry(self):
64+
self.session.request.return_value.status = 408
65+
66+
client = DaprHttpClient(
67+
message_serializer=self.serializer, retry_policy=RetryPolicy(max_attempts=0)
68+
)
69+
response = await client.retry_call(self.session, self.req)
70+
71+
self.session.request.assert_called_once()
72+
self.assertEqual(408, response.status)
73+
74+
@patch('asyncio.sleep', return_value=None)
75+
async def test_retry_eventually_succeeds(self, _):
76+
# Mock the request to fail twice then succeed
77+
self.session.request.side_effect = [
78+
MagicMock(status=500), # First attempt fails
79+
MagicMock(status=502), # Second attempt fails
80+
MagicMock(status=200), # Third attempt succeeds
81+
]
82+
client = DaprHttpClient(
83+
message_serializer=self.serializer, retry_policy=RetryPolicy(max_attempts=3)
84+
)
85+
86+
response = await client.retry_call(self.session, self.req)
87+
88+
self.assertEqual(3, self.session.request.call_count)
89+
self.assertEqual(200, response.status)
90+
91+
@patch('asyncio.sleep', return_value=None)
92+
async def test_retry_eventually_fails(self, _):
93+
self.session.request.return_value.status = 408
94+
95+
client = DaprHttpClient(
96+
message_serializer=self.serializer, retry_policy=RetryPolicy(max_attempts=3)
97+
)
98+
99+
response = await client.retry_call(self.session, self.req)
100+
101+
self.assertEqual(3, self.session.request.call_count)
102+
self.assertEqual(408, response.status)
103+
104+
@patch('asyncio.sleep', return_value=None)
105+
async def test_retry_fails_with_a_different_code(self, _):
106+
# Mock the request to fail twice then succeed
107+
self.session.request.return_value.status = 501
108+
109+
client = DaprHttpClient(
110+
message_serializer=self.serializer,
111+
retry_policy=RetryPolicy(max_attempts=3, retryable_http_status_codes=[500]),
112+
)
113+
114+
response = await client.retry_call(self.session, self.req)
115+
116+
self.session.request.assert_called_once()
117+
self.assertEqual(response.status, 501)
118+
119+
@patch('asyncio.sleep', return_value=None)
120+
async def test_retries_exhausted(self, _):
121+
# Mock the request to fail three times
122+
self.session.request.return_value = MagicMock(status=500)
123+
124+
client = DaprHttpClient(
125+
message_serializer=self.serializer,
126+
retry_policy=RetryPolicy(max_attempts=3, retryable_http_status_codes=[500]),
127+
)
128+
129+
response = await client.retry_call(self.session, self.req)
130+
131+
self.assertEqual(3, self.session.request.call_count)
132+
self.assertEqual(500, response.status)
133+
134+
@patch('asyncio.sleep', return_value=None)
135+
async def test_max_backoff(self, mock_sleep):
136+
self.session.request.return_value.status = 500
137+
138+
policy = RetryPolicy(max_attempts=4, initial_backoff=2, backoff_multiplier=2, max_backoff=3)
139+
client = DaprHttpClient(message_serializer=self.serializer, retry_policy=policy)
140+
141+
response = await client.retry_call(self.session, self.req)
142+
143+
expected_sleep_calls = [
144+
mock.call(2.0), # First sleep call
145+
mock.call(3.0), # Second sleep call
146+
mock.call(3.0), # Third sleep call
147+
]
148+
self.assertEqual(4, self.session.request.call_count)
149+
mock_sleep.assert_has_calls(expected_sleep_calls, any_order=False)
150+
self.assertEqual(500, response.status)
151+
152+
@patch('asyncio.sleep', return_value=None)
153+
async def test_infinite_retries(self, _):
154+
retry_count = 0
155+
max_test_retries = 6 # Simulates "indefinite" retries for test purposes
156+
157+
# Function to simulate request behavior
158+
async def mock_request(*args, **kwargs):
159+
nonlocal retry_count
160+
retry_count += 1
161+
if retry_count < max_test_retries:
162+
return MagicMock(status=500) # Simulate failure
163+
else:
164+
return MagicMock(status=200) # Simulate success to stop retrying
165+
166+
self.session.request = mock_request
167+
168+
policy = RetryPolicy(max_attempts=-1, retryable_http_status_codes=[500])
169+
client = DaprHttpClient(message_serializer=self.serializer, retry_policy=policy)
170+
171+
response = await client.retry_call(self.session, self.req)
172+
173+
# Assert that the retry logic was executed the expected number of times
174+
self.assertEqual(response.status, 200)
175+
self.assertEqual(retry_count, max_test_retries)

0 commit comments

Comments
 (0)