Skip to content

Commit 51caa32

Browse files
authored
Merge pull request #153 from abhinavrastogi2627/fix/client-errors-do-not-failover
fix: avoid failover on client-local HTTP errors
2 parents 308328f + a9be046 commit 51caa32

3 files changed

Lines changed: 109 additions & 0 deletions

File tree

‎src/typesense/async_/api_call.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,20 @@ class SessionFunctionKwargs(typing.Generic[TParams, TBody], typing.TypedDict):
135135
ServiceUnavailable,
136136
)
137137

138+
_CLIENT_ERRORS: typing.Final[
139+
typing.Tuple[
140+
typing.Type[httpx.PoolTimeout],
141+
typing.Type[httpx.LocalProtocolError],
142+
typing.Type[httpx.DecodingError],
143+
typing.Type[httpx.TooManyRedirects],
144+
]
145+
] = (
146+
httpx.PoolTimeout,
147+
httpx.LocalProtocolError,
148+
httpx.DecodingError,
149+
httpx.TooManyRedirects,
150+
)
151+
138152

139153
class AsyncApiCall:
140154
"""
@@ -478,6 +492,8 @@ async def _execute_request(
478492
as_json,
479493
**request_kwargs,
480494
)
495+
except _CLIENT_ERRORS:
496+
raise
481497
except _SERVER_ERRORS as server_error:
482498
self.node_manager.set_node_health(node, is_healthy=False)
483499
if num_retries < self.config.num_retries:

‎src/typesense/sync/api_call.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,20 @@ class SessionFunctionKwargs(typing.Generic[TParams, TBody], typing.TypedDict):
135135
ServiceUnavailable,
136136
)
137137

138+
_CLIENT_ERRORS: typing.Final[
139+
typing.Tuple[
140+
typing.Type[httpx.PoolTimeout],
141+
typing.Type[httpx.LocalProtocolError],
142+
typing.Type[httpx.DecodingError],
143+
typing.Type[httpx.TooManyRedirects],
144+
]
145+
] = (
146+
httpx.PoolTimeout,
147+
httpx.LocalProtocolError,
148+
httpx.DecodingError,
149+
httpx.TooManyRedirects,
150+
)
151+
138152

139153
class ApiCall:
140154
"""
@@ -478,6 +492,8 @@ def _execute_request(
478492
as_json,
479493
**request_kwargs,
480494
)
495+
except _CLIENT_ERRORS:
496+
raise
481497
except _SERVER_ERRORS as server_error:
482498
self.node_manager.set_node_health(node, is_healthy=False)
483499
if num_retries < self.config.num_retries:

‎tests/api_call_test.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,25 @@ def test_selects_next_available_node_on_timeout(
461461
assert len(respx.calls) == 3
462462

463463

464+
def test_client_errors_do_not_mark_nodes_unhealthy(
465+
fake_api_call: ApiCall,
466+
mocker: MockerFixture,
467+
) -> None:
468+
"""Pool exhaustion is local to the client and must not trigger failover."""
469+
node = fake_api_call.node_manager.get_node()
470+
make_request = mocker.patch.object(
471+
fake_api_call.request_handler,
472+
"make_request",
473+
side_effect=httpx.PoolTimeout("No connection available"),
474+
)
475+
476+
with pytest.raises(httpx.PoolTimeout):
477+
fake_api_call.get("/test", as_json=True, entity_type=typing.Dict[str, str])
478+
479+
assert node.healthy is True
480+
make_request.assert_called_once()
481+
482+
464483
def test_get_node_no_healthy_nodes(
465484
fake_api_call: ApiCall,
466485
mocker: MockFixture,
@@ -665,3 +684,61 @@ async def test_async_sleeps_retry_interval_between_retries(
665684
assert sleep_call == mocker.call(
666685
fake_async_api_call.config.retry_interval_seconds,
667686
)
687+
688+
689+
@pytest.mark.parametrize(
690+
"client_side_error",
691+
[
692+
httpx.PoolTimeout("Pool timeout"),
693+
httpx.LocalProtocolError("Local protocol error"),
694+
httpx.DecodingError("Decoding error"),
695+
httpx.TooManyRedirects("Too many redirects"),
696+
],
697+
)
698+
def test_client_side_error_does_not_mark_node_unhealthy(
699+
fake_api_call: ApiCall,
700+
client_side_error: httpx.HTTPError,
701+
) -> None:
702+
"""Test that client-side httpx errors propagate without failing over."""
703+
with respx.mock:
704+
respx.get("http://nearest:8108/").mock(side_effect=client_side_error)
705+
node0_route = respx.get("http://node0:8108/").mock(
706+
return_value=httpx.Response(200, json={"key": "value"}),
707+
)
708+
709+
with pytest.raises(type(client_side_error)):
710+
fake_api_call.get("/", entity_type=typing.Dict[str, str])
711+
712+
assert len(respx.calls) == 1
713+
assert not node0_route.called
714+
715+
assert fake_api_call.config.nearest_node.healthy is True
716+
717+
718+
@pytest.mark.parametrize(
719+
"client_side_error",
720+
[
721+
httpx.PoolTimeout("Pool timeout"),
722+
httpx.LocalProtocolError("Local protocol error"),
723+
httpx.DecodingError("Decoding error"),
724+
httpx.TooManyRedirects("Too many redirects"),
725+
],
726+
)
727+
async def test_async_client_side_error_does_not_mark_node_unhealthy(
728+
fake_async_api_call: AsyncApiCall,
729+
client_side_error: httpx.HTTPError,
730+
) -> None:
731+
"""Test that client-side httpx errors propagate without failing over (async)."""
732+
with respx.mock:
733+
respx.get("http://nearest:8108/").mock(side_effect=client_side_error)
734+
node0_route = respx.get("http://node0:8108/").mock(
735+
return_value=httpx.Response(200, json={"key": "value"}),
736+
)
737+
738+
with pytest.raises(type(client_side_error)):
739+
await fake_async_api_call.get("/", entity_type=typing.Dict[str, str])
740+
741+
assert len(respx.calls) == 1
742+
assert not node0_route.called
743+
744+
assert fake_async_api_call.config.nearest_node.healthy is True

0 commit comments

Comments
 (0)