From 8259658e174aa98a44a53727038cd2b513b1dde1 Mon Sep 17 00:00:00 2001 From: jaideeppyne Date: Mon, 24 Aug 2026 14:25:22 +0530 Subject: [PATCH] fix: wait retry_interval_seconds between retries (#140) The httpx rewrite (v1.0.0) dropped the delay between retries that the requests-based client had. `_execute_request` recursed straight into the next attempt on a server error, so `retry_interval_seconds` was stored but never read. A node returning 503 or timing out received all attempts within milliseconds instead of being spaced out, and since each failed attempt marks the node unhealthy, every retry landed before the node had any chance to recover. Restore the wait in both the sync and async clients, sleeping only between attempts (num_retries < config.num_retries) so there is no needless delay before the final failure is raised. Also address the related config-key mismatch: `retry_interval_seconds` was read by Configuration but absent from ConfigDict (so the working key failed type checking), while the documented `interval_seconds` was in ConfigDict but never read. Add `retry_interval_seconds` to ConfigDict and honor both spellings, mirroring the earlier fix for `connection_timeout_seconds` (#73). The async client is the unasync source of truth; add an asyncio->time token mapping so the generated sync client uses `time.sleep`. Adds regression tests for both the sync and async retry paths. --- src/typesense/async_/api_call.py | 3 ++ src/typesense/configuration.py | 14 +++++++-- src/typesense/sync/api_call.py | 3 ++ tests/api_call_test.py | 50 ++++++++++++++++++++++++++++++++ utils/run-unasync.py | 4 +++ 5 files changed, 71 insertions(+), 3 deletions(-) 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