Skip to content

Commit 9fd2e8d

Browse files
committed
added tests for RetryExecutor
1 parent 4750815 commit 9fd2e8d

6 files changed

Lines changed: 251 additions & 12 deletions

File tree

.gitlab-ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
include:
2-
project: mnt/ci
3-
file: modules/python.yaml
1+
#include:
2+
# project: mnt/ci
3+
# file: modules/python.yaml

extapi/http/abc.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ async def need_retry(self, response: Response[T]) -> tuple[bool, float | None]:
160160

161161
@runtime_checkable
162162
class Addon(Protocol[T]):
163-
async def before_request(self, request: RequestData) -> None: ...
163+
async def before_request(self, request: RequestData) -> None:
164+
return None
164165

165166
async def process_response(
166167
self, request: RequestData, response: Response[T]

extapi/http/addons/log.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ async def process_error(self, request: RequestData, error: Exception) -> None:
3737
"timeout error for request %s %s failed with error %s(%s)",
3838
request.method,
3939
str(request.url),
40-
type(error),
40+
type(error).__name__,
4141
str(error),
4242
)
4343
elif isinstance(error, HttpExecuteError):
@@ -47,7 +47,7 @@ async def process_error(self, request: RequestData, error: Exception) -> None:
4747
"request %s %s failed with error %s(%s)",
4848
request.method,
4949
str(request.url),
50-
type(error),
50+
type(error).__name__,
5151
error,
5252
)
5353

extapi/http/addons/retry.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@
77

88

99
class Retry5xxAddon(Retryable[T], Generic[T]):
10+
__slots__ = ("_retry_timeout",)
11+
12+
def __init__(self, *, retry_timeout: float | None = None):
13+
self._retry_timeout = retry_timeout
14+
1015
async def need_retry(self, response: Response[T]) -> tuple[bool, float | None]:
1116
if response.status >= 500:
12-
return True, None
17+
return True, self._retry_timeout
1318

1419
return False, None
1520

extapi/http/executors/retry.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ async def execute(self, request: RequestData) -> Response[T]:
122122
except Exception as e:
123123
self._logger.error(
124124
"error post-processing request execution error %s(%s): %s",
125-
type(last_exc),
125+
type(last_exc).__name__,
126126
last_exc,
127127
e,
128128
)
@@ -135,7 +135,7 @@ async def execute(self, request: RequestData) -> Response[T]:
135135

136136
if last_exc is not None:
137137
raise ExecuteError(
138-
f"request failed after {self._max_retries} retries: {str(last_exc)}"
138+
f"request failed after {self._max_retries} retries: {type(last_exc).__name__}({str(last_exc)})"
139139
) from last_exc
140-
else:
140+
else: # pragma: no cover
141141
raise ExecuteError(f"request failed after {self._max_retries} retries")
Lines changed: 235 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,236 @@
1+
import inspect
2+
import time
3+
from collections.abc import Iterable
4+
from typing import Any
5+
6+
import pytest
7+
8+
from extapi.http.abc import AbstractExecutor, Addon
9+
from extapi.http.addons.auth import BearerAuthAddon
10+
from extapi.http.addons.retry import Retry5xxAddon
11+
from extapi.http.executors.retry import RetryableExecutor
12+
from extapi.http.types import ExecuteError, HttpExecuteError, RequestData, Response
13+
14+
15+
class _DummyExecutor(AbstractExecutor[Any]):
16+
def __init__(self, responses: Iterable[int | type[Exception] | Exception] = (200,)):
17+
self.call_count = 0
18+
self._responses: list[int | type[Exception] | Exception] = list(responses)
19+
self._current_response_index = 0
20+
21+
async def execute(self, request: RequestData) -> Response:
22+
self.call_count += 1
23+
if self._current_response_index < len(self._responses):
24+
response = self._responses[self._current_response_index]
25+
self._current_response_index += 1
26+
else: # pragma: no cover
27+
response = self._responses[-1] # Return the last response if we've run out
28+
29+
if isinstance(response, Exception) or (
30+
inspect.isclass(response) and issubclass(response, Exception)
31+
):
32+
raise response
33+
34+
if isinstance(response, int):
35+
return Response(status=response, backend_response="heyo", url=request.url)
36+
37+
raise BaseException(
38+
f"unexpected response type: {type(response)}"
39+
) # pragma: no cover
40+
41+
142
class TestRetryExecutor:
2-
def test(self):
3-
pass
43+
async def test_basic(self, request_simple: RequestData):
44+
base = _DummyExecutor()
45+
executor = RetryableExecutor(base, retry_sleep_timeout=0)
46+
47+
response = await executor.execute(request_simple)
48+
49+
assert response.status == 200
50+
assert response.backend_response == "heyo"
51+
assert response.url == request_simple.url
52+
53+
async def test_retry_500_no_addon(self, request_simple: RequestData):
54+
base = _DummyExecutor(responses=[500, 200])
55+
executor = RetryableExecutor(base, max_retries=2, retry_sleep_timeout=0)
56+
57+
response = await executor.execute(request_simple)
58+
59+
assert response.status == 500
60+
assert base.call_count == 1
61+
62+
async def test_retry_500_with_addon(self, request_simple: RequestData):
63+
base = _DummyExecutor(responses=[500, 200])
64+
executor = RetryableExecutor(
65+
base,
66+
max_retries=2,
67+
retry_sleep_timeout=0,
68+
addons=[
69+
Retry5xxAddon(),
70+
],
71+
)
72+
73+
response = await executor.execute(request_simple)
74+
75+
assert response.status == 200
76+
assert base.call_count == 2
77+
78+
async def test_max_retries_exceeded(self, request_simple: RequestData):
79+
base = _DummyExecutor(responses=[500, 500, 500])
80+
executor = RetryableExecutor(
81+
base,
82+
max_retries=3,
83+
retry_sleep_timeout=0,
84+
addons=[
85+
Retry5xxAddon(),
86+
],
87+
)
88+
89+
response = await executor.execute(request_simple)
90+
91+
assert response.status == 500
92+
assert base.call_count == 3
93+
94+
async def test_non_retryable_status(self, request_simple: RequestData):
95+
base = _DummyExecutor(responses=[400])
96+
executor = RetryableExecutor(
97+
base,
98+
max_retries=2,
99+
retry_sleep_timeout=0,
100+
addons=[
101+
Retry5xxAddon(),
102+
],
103+
)
104+
105+
response = await executor.execute(request_simple)
106+
107+
assert response.status == 400
108+
assert base.call_count == 1
109+
110+
async def test_retry_with_bearer_auth_error(self, request_simple: RequestData):
111+
base = _DummyExecutor(responses=[401, 401, 200])
112+
executor = RetryableExecutor(
113+
base,
114+
max_retries=2,
115+
retry_sleep_timeout=0,
116+
addons=[BearerAuthAddon(lambda: "token")],
117+
)
118+
119+
response = await executor.execute(request_simple)
120+
121+
assert response.status == 401
122+
assert base.call_count == 2
123+
124+
async def test_retry_with_bearer_auth_success(self, request_simple: RequestData):
125+
base = _DummyExecutor(responses=[401, 401, 200])
126+
executor = RetryableExecutor(
127+
base,
128+
max_retries=3,
129+
retry_sleep_timeout=0,
130+
addons=[BearerAuthAddon(lambda: "token")],
131+
)
132+
133+
response = await executor.execute(request_simple)
134+
135+
assert response.status == 200
136+
assert base.call_count == 3
137+
138+
async def test_custom_retry_timeout(self, request_simple: RequestData):
139+
base = _DummyExecutor(responses=[500, 200])
140+
executor = RetryableExecutor(
141+
base,
142+
max_retries=2,
143+
retry_sleep_timeout=0,
144+
addons=[
145+
Retry5xxAddon(retry_timeout=1),
146+
],
147+
)
148+
149+
started_at = time.monotonic()
150+
response = await executor.execute(request_simple)
151+
elapsed = time.monotonic() - started_at
152+
153+
assert elapsed >= 1
154+
155+
assert response.status == 200
156+
assert base.call_count == 2
157+
158+
async def test_timeout_error(self, request_simple: RequestData):
159+
base = _DummyExecutor(responses=[TimeoutError, TimeoutError(), 200])
160+
executor = RetryableExecutor(base, max_retries=3, retry_sleep_timeout=0)
161+
162+
response = await executor.execute(request_simple)
163+
164+
assert response.status == 200
165+
assert base.call_count == 3
166+
167+
async def test_timeout_error_propagate(self, request_simple: RequestData):
168+
base = _DummyExecutor(responses=[TimeoutError, 200])
169+
executor = RetryableExecutor(base, max_retries=1, retry_sleep_timeout=0)
170+
171+
with pytest.raises(ExecuteError) as err:
172+
await executor.execute(request_simple)
173+
174+
assert str(err.value) == "request failed after 1 retries: TimeoutError()"
175+
176+
async def test_exception_success(self, request_simple: RequestData):
177+
base = _DummyExecutor(responses=[Exception("some error"), 200])
178+
executor = RetryableExecutor(base, max_retries=3, retry_sleep_timeout=0)
179+
180+
response = await executor.execute(request_simple)
181+
182+
assert response.status == 200
183+
assert base.call_count == 2
184+
185+
async def test_exception_propagate(self, request_simple: RequestData):
186+
base = _DummyExecutor(responses=[Exception("some error"), 200])
187+
executor = RetryableExecutor(base, max_retries=1, retry_sleep_timeout=0)
188+
189+
with pytest.raises(Exception) as err:
190+
await executor.execute(request_simple)
191+
192+
assert str(err.value) == "request failed after 1 retries: Exception(some error)"
193+
194+
async def test_propagate_execute_error(self, request_simple: RequestData):
195+
resp = Response(status=404, backend_response=None, url=request_simple.url)
196+
base = _DummyExecutor(responses=[HttpExecuteError(resp), 200])
197+
executor = RetryableExecutor(base, max_retries=1, retry_sleep_timeout=0)
198+
199+
with pytest.raises(ExecuteError) as err:
200+
await executor.execute(request_simple)
201+
202+
assert str(err.value) == "HTTPExecuteError(url=https://example.com, status=404)"
203+
204+
async def test_with_process_error_addon(self, request_simple: RequestData):
205+
class _Addon(Addon):
206+
async def process_error(
207+
self, request: RequestData, error: Exception
208+
) -> None:
209+
raise Exception("error processing exception")
210+
211+
base = _DummyExecutor(responses=[Exception("some error"), 200])
212+
executor = RetryableExecutor(
213+
base, max_retries=1, retry_sleep_timeout=0, addons=[_Addon()]
214+
)
215+
216+
with pytest.raises(ExecuteError) as err:
217+
await executor.execute(request_simple)
218+
219+
assert str(err.value) == "request failed after 1 retries: Exception(some error)"
220+
221+
async def test_with_process_error_addon_success(self, request_simple: RequestData):
222+
class _Addon(Addon):
223+
async def process_error(
224+
self, request: RequestData, error: Exception
225+
) -> None:
226+
raise Exception("error processing exception")
227+
228+
base = _DummyExecutor(responses=[Exception("some error"), 200])
229+
executor = RetryableExecutor(
230+
base, max_retries=2, retry_sleep_timeout=0, addons=[_Addon()]
231+
)
232+
233+
response = await executor.execute(request_simple)
234+
235+
assert response.status == 200
236+
assert base.call_count == 2

0 commit comments

Comments
 (0)