Skip to content
Merged
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
16 changes: 16 additions & 0 deletions src/typesense/async_/api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@ class SessionFunctionKwargs(typing.Generic[TParams, TBody], typing.TypedDict):
ServiceUnavailable,
)

_CLIENT_ERRORS: typing.Final[
typing.Tuple[
typing.Type[httpx.PoolTimeout],
typing.Type[httpx.LocalProtocolError],
typing.Type[httpx.DecodingError],
typing.Type[httpx.TooManyRedirects],
]
] = (
httpx.PoolTimeout,
httpx.LocalProtocolError,
httpx.DecodingError,
httpx.TooManyRedirects,
)


class AsyncApiCall:
"""
Expand Down Expand Up @@ -478,6 +492,8 @@ async def _execute_request(
as_json,
**request_kwargs,
)
except _CLIENT_ERRORS:
raise
except _SERVER_ERRORS as server_error:
self.node_manager.set_node_health(node, is_healthy=False)
if num_retries < self.config.num_retries:
Expand Down
16 changes: 16 additions & 0 deletions src/typesense/sync/api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,20 @@ class SessionFunctionKwargs(typing.Generic[TParams, TBody], typing.TypedDict):
ServiceUnavailable,
)

_CLIENT_ERRORS: typing.Final[
typing.Tuple[
typing.Type[httpx.PoolTimeout],
typing.Type[httpx.LocalProtocolError],
typing.Type[httpx.DecodingError],
typing.Type[httpx.TooManyRedirects],
]
] = (
httpx.PoolTimeout,
httpx.LocalProtocolError,
httpx.DecodingError,
httpx.TooManyRedirects,
)


class ApiCall:
"""
Expand Down Expand Up @@ -478,6 +492,8 @@ def _execute_request(
as_json,
**request_kwargs,
)
except _CLIENT_ERRORS:
raise
except _SERVER_ERRORS as server_error:
self.node_manager.set_node_health(node, is_healthy=False)
if num_retries < self.config.num_retries:
Expand Down
77 changes: 77 additions & 0 deletions tests/api_call_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,25 @@ def test_selects_next_available_node_on_timeout(
assert len(respx.calls) == 3


def test_client_errors_do_not_mark_nodes_unhealthy(
fake_api_call: ApiCall,
mocker: MockerFixture,
) -> None:
"""Pool exhaustion is local to the client and must not trigger failover."""
node = fake_api_call.node_manager.get_node()
make_request = mocker.patch.object(
fake_api_call.request_handler,
"make_request",
side_effect=httpx.PoolTimeout("No connection available"),
)

with pytest.raises(httpx.PoolTimeout):
fake_api_call.get("/test", as_json=True, entity_type=typing.Dict[str, str])

assert node.healthy is True
make_request.assert_called_once()


def test_get_node_no_healthy_nodes(
fake_api_call: ApiCall,
mocker: MockFixture,
Expand Down Expand Up @@ -665,3 +684,61 @@ async def test_async_sleeps_retry_interval_between_retries(
assert sleep_call == mocker.call(
fake_async_api_call.config.retry_interval_seconds,
)


@pytest.mark.parametrize(
"client_side_error",
[
httpx.PoolTimeout("Pool timeout"),
httpx.LocalProtocolError("Local protocol error"),
httpx.DecodingError("Decoding error"),
httpx.TooManyRedirects("Too many redirects"),
],
)
def test_client_side_error_does_not_mark_node_unhealthy(
fake_api_call: ApiCall,
client_side_error: httpx.HTTPError,
) -> None:
"""Test that client-side httpx errors propagate without failing over."""
with respx.mock:
respx.get("http://nearest:8108/").mock(side_effect=client_side_error)
node0_route = respx.get("http://node0:8108/").mock(
return_value=httpx.Response(200, json={"key": "value"}),
)

with pytest.raises(type(client_side_error)):
fake_api_call.get("/", entity_type=typing.Dict[str, str])

assert len(respx.calls) == 1
assert not node0_route.called

assert fake_api_call.config.nearest_node.healthy is True


@pytest.mark.parametrize(
"client_side_error",
[
httpx.PoolTimeout("Pool timeout"),
httpx.LocalProtocolError("Local protocol error"),
httpx.DecodingError("Decoding error"),
httpx.TooManyRedirects("Too many redirects"),
],
)
async def test_async_client_side_error_does_not_mark_node_unhealthy(
fake_async_api_call: AsyncApiCall,
client_side_error: httpx.HTTPError,
) -> None:
"""Test that client-side httpx errors propagate without failing over (async)."""
with respx.mock:
respx.get("http://nearest:8108/").mock(side_effect=client_side_error)
node0_route = respx.get("http://node0:8108/").mock(
return_value=httpx.Response(200, json={"key": "value"}),
)

with pytest.raises(type(client_side_error)):
await fake_async_api_call.get("/", entity_type=typing.Dict[str, str])

assert len(respx.calls) == 1
assert not node0_route.called

assert fake_async_api_call.config.nearest_node.healthy is True
Loading