Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/typesense/async_/api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
by other components of the library.
"""

import asyncio
import sys
from types import MappingProxyType, TracebackType

Expand Down Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions src/typesense/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/typesense/sync/api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
by other components of the library.
"""

import time
import sys
from types import MappingProxyType, TracebackType

Expand Down Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions tests/api_call_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
)
4 changes: 4 additions & 0 deletions utils/run-unasync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down