Skip to content

Commit 8259658

Browse files
committed
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.
1 parent e863b44 commit 8259658

5 files changed

Lines changed: 71 additions & 3 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: 11 additions & 3 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
@@ -214,7 +217,12 @@ def __init__(
214217
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+
)

‎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)