Skip to content

Commit 308328f

Browse files
authored
Merge pull request #141 from jaideeppyne/fix/retry-interval-delay
fix: wait retry_interval_seconds between retries (#140)
2 parents ef3a3db + 0678304 commit 308328f

6 files changed

Lines changed: 95 additions & 4 deletions

File tree

‎src/typesense/async_/api_call.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
by other components of the library.
3232
"""
3333

34+
import asyncio
3435
import sys
3536
from types import MappingProxyType, TracebackType
3637

@@ -479,6 +480,8 @@ async def _execute_request(
479480
)
480481
except _SERVER_ERRORS as server_error:
481482
self.node_manager.set_node_health(node, is_healthy=False)
483+
if num_retries < self.config.num_retries:
484+
await asyncio.sleep(self.config.retry_interval_seconds)
482485
return await self._execute_request(
483486
method,
484487
endpoint,

‎src/typesense/configuration.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ class ConfigDict(typing.TypedDict):
6060
6161
num_retries (int): The number of retries to attempt before failing.
6262
63-
interval_seconds (int): The interval in seconds between retries.
63+
retry_interval_seconds (float): The interval in seconds between retries.
64+
65+
interval_seconds (int): Deprecated alias of ``retry_interval_seconds``.
6466
6567
healthcheck_interval_seconds (int): The interval in seconds between
6668
health checks.
@@ -86,7 +88,8 @@ class ConfigDict(typing.TypedDict):
8688
nearest_node: typing.NotRequired[typing.Union[str, NodeConfigDict]]
8789
api_key: str
8890
num_retries: typing.NotRequired[int]
89-
interval_seconds: typing.NotRequired[int]
91+
retry_interval_seconds: typing.NotRequired[float]
92+
interval_seconds: typing.NotRequired[int] # deprecated alias
9093
healthcheck_interval_seconds: typing.NotRequired[int]
9194
verify: typing.NotRequired[bool]
9295
timeout_seconds: typing.NotRequired[int] # deprecated
@@ -211,10 +214,15 @@ def __init__(
211214
self.api_key = config_dict.get("api_key", " ")
212215
self.connection_timeout_seconds = config_dict.get(
213216
"connection_timeout_seconds",
214-
3.0,
217+
config_dict.get("timeout_seconds", 3.0),
215218
)
216219
self.num_retries = config_dict.get("num_retries", 3)
217-
self.retry_interval_seconds = config_dict.get("retry_interval_seconds", 1.0)
220+
# ``interval_seconds`` is the historically documented key; ``retry_interval_seconds``
221+
# is what this attribute is named. Honor both so the documented spelling works too.
222+
self.retry_interval_seconds = config_dict.get(
223+
"retry_interval_seconds",
224+
config_dict.get("interval_seconds", 1.0),
225+
)
218226
self.healthcheck_interval_seconds = config_dict.get(
219227
"healthcheck_interval_seconds",
220228
60,

‎src/typesense/sync/api_call.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
by other components of the library.
3232
"""
3333

34+
import time
3435
import sys
3536
from types import MappingProxyType, TracebackType
3637

@@ -479,6 +480,8 @@ def _execute_request(
479480
)
480481
except _SERVER_ERRORS as server_error:
481482
self.node_manager.set_node_health(node, is_healthy=False)
483+
if num_retries < self.config.num_retries:
484+
time.sleep(self.config.retry_interval_seconds)
482485
return self._execute_request(
483486
method,
484487
endpoint,

‎tests/api_call_test.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from tests.utils.object_assertions import assert_match_object, assert_object_lists_match
2020
from typesense import exceptions
2121
from typesense.sync.api_call import ApiCall, RequestHandler
22+
from typesense.async_.api_call import AsyncApiCall
2223
from typesense.configuration import Configuration, Node
2324
from typesense.logger import logger
2425

@@ -615,3 +616,52 @@ def test_max_retries_no_last_exception(fake_api_call: ApiCall) -> None:
615616
num_retries=10,
616617
last_exception=None,
617618
)
619+
620+
621+
def test_sleeps_retry_interval_between_retries(
622+
fake_api_call: ApiCall,
623+
mocker: MockerFixture,
624+
) -> None:
625+
"""Test that it waits ``retry_interval_seconds`` between failed attempts."""
626+
sleep_mock = mocker.patch("typesense.sync.api_call.time.sleep")
627+
628+
with respx.mock:
629+
for host in ("nearest", "node0", "node1", "node2"):
630+
respx.get(f"http://{host}:8108/").mock(
631+
return_value=httpx.Response(503, json={"message": "unavailable"}),
632+
)
633+
634+
with pytest.raises(exceptions.ServiceUnavailable):
635+
fake_api_call.get("/", entity_type=typing.Dict[str, str])
636+
637+
# ``num_retries`` gaps for ``num_retries + 1`` attempts, and each gap must be
638+
# ``retry_interval_seconds`` long (regression: the delay was dropped entirely).
639+
assert sleep_mock.call_count == fake_api_call.config.num_retries
640+
for sleep_call in sleep_mock.call_args_list:
641+
assert sleep_call == mocker.call(fake_api_call.config.retry_interval_seconds)
642+
643+
644+
async def test_async_sleeps_retry_interval_between_retries(
645+
fake_async_api_call: AsyncApiCall,
646+
mocker: MockerFixture,
647+
) -> None:
648+
"""Test that the async client waits ``retry_interval_seconds`` between attempts."""
649+
sleep_mock = mocker.patch(
650+
"typesense.async_.api_call.asyncio.sleep",
651+
new_callable=mocker.AsyncMock,
652+
)
653+
654+
with respx.mock:
655+
for host in ("nearest", "node0", "node1", "node2"):
656+
respx.get(f"http://{host}:8108/").mock(
657+
return_value=httpx.Response(503, json={"message": "unavailable"}),
658+
)
659+
660+
with pytest.raises(exceptions.ServiceUnavailable):
661+
await fake_async_api_call.get("/", entity_type=typing.Dict[str, str])
662+
663+
assert sleep_mock.call_count == fake_async_api_call.config.num_retries
664+
for sleep_call in sleep_mock.call_args_list:
665+
assert sleep_call == mocker.call(
666+
fake_async_api_call.config.retry_interval_seconds,
667+
)

‎tests/configuration_test.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,29 @@ def test_configuration_explicit() -> None:
8989
assert_to_contain_object(configuration, expected)
9090

9191

92+
def test_configuration_deprecated_timeout_seconds() -> None:
93+
"""Test that the deprecated timeout key still sets the connection timeout."""
94+
config: ConfigDict = {
95+
"nodes": [DEFAULT_NODE],
96+
"api_key": "xyz",
97+
"timeout_seconds": 30,
98+
}
99+
100+
assert Configuration(config).connection_timeout_seconds == 30
101+
102+
103+
def test_configuration_new_timeout_takes_precedence() -> None:
104+
"""Test that the current timeout key wins when both keys are provided."""
105+
config: ConfigDict = {
106+
"nodes": [DEFAULT_NODE],
107+
"api_key": "xyz",
108+
"timeout_seconds": 30,
109+
"connection_timeout_seconds": 5.0,
110+
}
111+
112+
assert Configuration(config).connection_timeout_seconds == 5.0
113+
114+
92115
def test_configuration_no_nearest_node() -> None:
93116
"""Test the Configuration constructor with no nearest node."""
94117
config: ConfigDict = {

‎utils/run-unasync.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ def collect_class_replacements(source_dir: Path) -> dict[str, str]:
2323
async_name = match.group(1)
2424
replacements[async_name] = async_name[len("Async") :]
2525
replacements["aclose"] = "close"
26+
# ``await asyncio.sleep`` in the async client becomes ``time.sleep`` in the sync
27+
# client (unasync strips ``await``); map the module token so the import and call
28+
# are rewritten too.
29+
replacements["asyncio"] = "time"
2630
return replacements
2731

2832

0 commit comments

Comments
 (0)