diff --git a/src/typesense/async_/api_call.py b/src/typesense/async_/api_call.py index 0953310..be1a83d 100644 --- a/src/typesense/async_/api_call.py +++ b/src/typesense/async_/api_call.py @@ -31,6 +31,7 @@ by other components of the library. """ +import asyncio import sys from types import MappingProxyType, TracebackType @@ -479,6 +480,8 @@ async def _execute_request( ) except _SERVER_ERRORS as server_error: self.node_manager.set_node_health(node, is_healthy=False) + if num_retries < self.config.num_retries: + await asyncio.sleep(self.config.retry_interval_seconds) return await self._execute_request( method, endpoint, diff --git a/src/typesense/configuration.py b/src/typesense/configuration.py index 85159cd..db2200a 100644 --- a/src/typesense/configuration.py +++ b/src/typesense/configuration.py @@ -60,7 +60,9 @@ class ConfigDict(typing.TypedDict): num_retries (int): The number of retries to attempt before failing. - interval_seconds (int): The interval in seconds between retries. + retry_interval_seconds (float): The interval in seconds between retries. + + interval_seconds (int): Deprecated alias of ``retry_interval_seconds``. healthcheck_interval_seconds (int): The interval in seconds between health checks. @@ -86,7 +88,8 @@ class ConfigDict(typing.TypedDict): nearest_node: typing.NotRequired[typing.Union[str, NodeConfigDict]] api_key: str num_retries: typing.NotRequired[int] - interval_seconds: typing.NotRequired[int] + retry_interval_seconds: typing.NotRequired[float] + interval_seconds: typing.NotRequired[int] # deprecated alias healthcheck_interval_seconds: typing.NotRequired[int] verify: typing.NotRequired[bool] timeout_seconds: typing.NotRequired[int] # deprecated @@ -214,7 +217,12 @@ def __init__( 3.0, ) self.num_retries = config_dict.get("num_retries", 3) - self.retry_interval_seconds = config_dict.get("retry_interval_seconds", 1.0) + # ``interval_seconds`` is the historically documented key; ``retry_interval_seconds`` + # is what this attribute is named. Honor both so the documented spelling works too. + self.retry_interval_seconds = config_dict.get( + "retry_interval_seconds", + config_dict.get("interval_seconds", 1.0), + ) self.healthcheck_interval_seconds = config_dict.get( "healthcheck_interval_seconds", 60, diff --git a/src/typesense/sync/api_call.py b/src/typesense/sync/api_call.py index a24ce6a..402a0dc 100644 --- a/src/typesense/sync/api_call.py +++ b/src/typesense/sync/api_call.py @@ -31,6 +31,7 @@ by other components of the library. """ +import time import sys from types import MappingProxyType, TracebackType @@ -479,6 +480,8 @@ def _execute_request( ) except _SERVER_ERRORS as server_error: self.node_manager.set_node_health(node, is_healthy=False) + if num_retries < self.config.num_retries: + time.sleep(self.config.retry_interval_seconds) return self._execute_request( method, endpoint, diff --git a/tests/api_call_test.py b/tests/api_call_test.py index ddff4ee..b7c4888 100644 --- a/tests/api_call_test.py +++ b/tests/api_call_test.py @@ -19,6 +19,7 @@ from tests.utils.object_assertions import assert_match_object, assert_object_lists_match from typesense import exceptions from typesense.sync.api_call import ApiCall, RequestHandler +from typesense.async_.api_call import AsyncApiCall from typesense.configuration import Configuration, Node from typesense.logger import logger @@ -615,3 +616,52 @@ def test_max_retries_no_last_exception(fake_api_call: ApiCall) -> None: num_retries=10, last_exception=None, ) + + +def test_sleeps_retry_interval_between_retries( + fake_api_call: ApiCall, + mocker: MockerFixture, +) -> None: + """Test that it waits ``retry_interval_seconds`` between failed attempts.""" + sleep_mock = mocker.patch("typesense.sync.api_call.time.sleep") + + with respx.mock: + for host in ("nearest", "node0", "node1", "node2"): + respx.get(f"http://{host}:8108/").mock( + return_value=httpx.Response(503, json={"message": "unavailable"}), + ) + + with pytest.raises(exceptions.ServiceUnavailable): + fake_api_call.get("/", entity_type=typing.Dict[str, str]) + + # ``num_retries`` gaps for ``num_retries + 1`` attempts, and each gap must be + # ``retry_interval_seconds`` long (regression: the delay was dropped entirely). + assert sleep_mock.call_count == fake_api_call.config.num_retries + for sleep_call in sleep_mock.call_args_list: + assert sleep_call == mocker.call(fake_api_call.config.retry_interval_seconds) + + +async def test_async_sleeps_retry_interval_between_retries( + fake_async_api_call: AsyncApiCall, + mocker: MockerFixture, +) -> None: + """Test that the async client waits ``retry_interval_seconds`` between attempts.""" + sleep_mock = mocker.patch( + "typesense.async_.api_call.asyncio.sleep", + new_callable=mocker.AsyncMock, + ) + + with respx.mock: + for host in ("nearest", "node0", "node1", "node2"): + respx.get(f"http://{host}:8108/").mock( + return_value=httpx.Response(503, json={"message": "unavailable"}), + ) + + with pytest.raises(exceptions.ServiceUnavailable): + await fake_async_api_call.get("/", entity_type=typing.Dict[str, str]) + + assert sleep_mock.call_count == fake_async_api_call.config.num_retries + for sleep_call in sleep_mock.call_args_list: + assert sleep_call == mocker.call( + fake_async_api_call.config.retry_interval_seconds, + ) diff --git a/utils/run-unasync.py b/utils/run-unasync.py index 49feabe..aa4dcbd 100644 --- a/utils/run-unasync.py +++ b/utils/run-unasync.py @@ -23,6 +23,10 @@ def collect_class_replacements(source_dir: Path) -> dict[str, str]: async_name = match.group(1) replacements[async_name] = async_name[len("Async") :] replacements["aclose"] = "close" + # ``await asyncio.sleep`` in the async client becomes ``time.sleep`` in the sync + # client (unasync strips ``await``); map the module token so the import and call + # are rewritten too. + replacements["asyncio"] = "time" return replacements