Skip to content

Commit 15cdb4e

Browse files
committed
refactor(api-call): simplify http request logic
- Extract node management logic into `NodeManager` class - Update ApiCall to use NodeManager for node operations - Utilize `RequestHandler` for request execution - Adjust tests to reflect new structure and dependencies - Improve error handling and retry logic in ApiCall
1 parent 5891ade commit 15cdb4e

25 files changed

+483
-747
lines changed

src/typesense/api_call.py

+309-677
Large diffs are not rendered by default.

tests/alias_test.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ def test_init(fake_api_call: ApiCall) -> None:
2121

2222
assert alias.name == "company_alias"
2323
assert_match_object(alias.api_call, fake_api_call)
24-
assert_object_lists_match(alias.api_call.nodes, fake_api_call.nodes)
24+
assert_object_lists_match(
25+
alias.api_call.node_manager.nodes,
26+
fake_api_call.node_manager.nodes,
27+
)
2528
assert_match_object(
2629
alias.api_call.config.nearest_node,
2730
fake_api_call.config.nearest_node,

tests/aliases_test.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ def test_init(fake_api_call: ApiCall) -> None:
1919
aliases = Aliases(fake_api_call)
2020

2121
assert_match_object(aliases.api_call, fake_api_call)
22-
assert_object_lists_match(aliases.api_call.nodes, fake_api_call.nodes)
22+
assert_object_lists_match(
23+
aliases.api_call.node_manager.nodes,
24+
fake_api_call.node_manager.nodes,
25+
)
2326
assert_match_object(
2427
aliases.api_call.config.nearest_node,
2528
fake_api_call.config.nearest_node,
@@ -34,7 +37,9 @@ def test_get_missing_alias(fake_aliases: Aliases) -> None:
3437

3538
assert alias.name == "company_alias"
3639
assert_match_object(alias.api_call, fake_aliases.api_call)
37-
assert_object_lists_match(alias.api_call.nodes, fake_aliases.api_call.nodes)
40+
assert_object_lists_match(
41+
alias.api_call.node_manager.nodes, fake_aliases.api_call.node_manager.nodes
42+
)
3843
assert_match_object(
3944
alias.api_call.config.nearest_node,
4045
fake_aliases.api_call.config.nearest_node,

tests/analytics_rule_test.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ def test_init(fake_api_call: ApiCall) -> None:
1717

1818
assert analytics_rule.rule_id == "company_analytics_rule"
1919
assert_match_object(analytics_rule.api_call, fake_api_call)
20-
assert_object_lists_match(analytics_rule.api_call.nodes, fake_api_call.nodes)
20+
assert_object_lists_match(
21+
analytics_rule.api_call.node_manager.nodes,
22+
fake_api_call.node_manager.nodes,
23+
)
2124
assert_match_object(
2225
analytics_rule.api_call.config.nearest_node,
2326
fake_api_call.config.nearest_node,

tests/analytics_rules_test.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ def test_init(fake_api_call: ApiCall) -> None:
1818
analytics_rules = AnalyticsRules(fake_api_call)
1919

2020
assert_match_object(analytics_rules.api_call, fake_api_call)
21-
assert_object_lists_match(analytics_rules.api_call.nodes, fake_api_call.nodes)
21+
assert_object_lists_match(
22+
analytics_rules.api_call.node_manager.nodes,
23+
fake_api_call.node_manager.nodes,
24+
)
2225
assert_match_object(
2326
analytics_rules.api_call.config.nearest_node,
2427
fake_api_call.config.nearest_node,
@@ -34,8 +37,8 @@ def test_get_missing_analytics_rule(fake_analytics_rules: AnalyticsRules) -> Non
3437
assert analytics_rule.rule_id == "company_analytics_rule"
3538
assert_match_object(analytics_rule.api_call, fake_analytics_rules.api_call)
3639
assert_object_lists_match(
37-
analytics_rule.api_call.nodes,
38-
fake_analytics_rules.api_call.nodes,
40+
analytics_rule.api_call.node_manager.nodes,
41+
fake_analytics_rules.api_call.node_manager.nodes,
3942
)
4043
assert_match_object(
4144
analytics_rule.api_call.config.nearest_node,

tests/analytics_test.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ def test_init(fake_api_call: ApiCall) -> None:
1010
analytics = Analytics(fake_api_call)
1111

1212
assert_match_object(analytics.rules.api_call, fake_api_call)
13-
assert_object_lists_match(analytics.rules.api_call.nodes, fake_api_call.nodes)
13+
assert_object_lists_match(
14+
analytics.rules.api_call.node_manager.nodes,
15+
fake_api_call.node_manager.nodes,
16+
)
1417
assert_match_object(
1518
analytics.rules.api_call.config.nearest_node,
1619
fake_api_call.config.nearest_node,

tests/api_call_test.py

+56-34
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from tests.utils.object_assertions import assert_match_object, assert_object_lists_match
2222
from typesense import exceptions
23-
from typesense.api_call import ApiCall
23+
from typesense.api_call import ApiCall, RequestHandler
2424
from typesense.configuration import Configuration, Node
2525
from typesense.logger import logger
2626

@@ -76,8 +76,8 @@ def test_initialization(
7676
) -> None:
7777
"""Test the initialization of the ApiCall object."""
7878
assert api_call.config == config
79-
assert_object_lists_match(api_call.nodes, config.nodes)
80-
assert api_call.node_index == 0
79+
assert_object_lists_match(api_call.node_manager.nodes, config.nodes)
80+
assert api_call.node_manager.node_index == 0
8181

8282

8383
def test_node_due_for_health_check(
@@ -86,14 +86,14 @@ def test_node_due_for_health_check(
8686
"""Test that it correctly identifies if a node is due for health check."""
8787
node = Node(host="localhost", port=8108, protocol="http", path=" ")
8888
node.last_access_ts = time.time() - 61
89-
assert api_call.node_due_for_health_check(node) is True
89+
assert api_call.node_manager._is_due_for_health_check(node) is True
9090

9191

9292
def test_get_node_nearest_healthy(
9393
api_call: ApiCall,
9494
) -> None:
9595
"""Test that it correctly selects the nearest node if it is healthy."""
96-
node = api_call.get_node()
96+
node = api_call.node_manager.get_node()
9797
assert_match_object(node, api_call.config.nearest_node)
9898

9999

@@ -102,8 +102,8 @@ def test_get_node_nearest_not_healthy(
102102
) -> None:
103103
"""Test that it selects the next available node if the nearest node is not healthy."""
104104
api_call.config.nearest_node.healthy = False
105-
node = api_call.get_node()
106-
assert_match_object(node, api_call.nodes[0])
105+
node = api_call.node_manager.get_node()
106+
assert_match_object(node, api_call.node_manager.nodes[0])
107107

108108

109109
def test_get_node_round_robin_selection(
@@ -114,34 +114,34 @@ def test_get_node_round_robin_selection(
114114
api_call.config.nearest_node = None
115115
mocker.patch("time.time", return_value=100)
116116

117-
node1 = api_call.get_node()
117+
node1 = api_call.node_manager.get_node()
118118
assert_match_object(node1, api_call.config.nodes[0])
119119

120-
node2 = api_call.get_node()
120+
node2 = api_call.node_manager.get_node()
121121
assert_match_object(node2, api_call.config.nodes[1])
122122

123-
node3 = api_call.get_node()
123+
node3 = api_call.node_manager.get_node()
124124
assert_match_object(node3, api_call.config.nodes[2])
125125

126126

127127
def test_get_exception() -> None:
128128
"""Test that it correctly returns the exception class for a given status code."""
129-
assert ApiCall.get_exception(0) == exceptions.HTTPStatus0Error
130-
assert ApiCall.get_exception(400) == exceptions.RequestMalformed
131-
assert ApiCall.get_exception(401) == exceptions.RequestUnauthorized
132-
assert ApiCall.get_exception(403) == exceptions.RequestForbidden
133-
assert ApiCall.get_exception(404) == exceptions.ObjectNotFound
134-
assert ApiCall.get_exception(409) == exceptions.ObjectAlreadyExists
135-
assert ApiCall.get_exception(422) == exceptions.ObjectUnprocessable
136-
assert ApiCall.get_exception(500) == exceptions.ServerError
137-
assert ApiCall.get_exception(503) == exceptions.ServiceUnavailable
138-
assert ApiCall.get_exception(999) == exceptions.TypesenseClientError
129+
assert RequestHandler._get_exception(0) == exceptions.HTTPStatus0Error
130+
assert RequestHandler._get_exception(400) == exceptions.RequestMalformed
131+
assert RequestHandler._get_exception(401) == exceptions.RequestUnauthorized
132+
assert RequestHandler._get_exception(403) == exceptions.RequestForbidden
133+
assert RequestHandler._get_exception(404) == exceptions.ObjectNotFound
134+
assert RequestHandler._get_exception(409) == exceptions.ObjectAlreadyExists
135+
assert RequestHandler._get_exception(422) == exceptions.ObjectUnprocessable
136+
assert RequestHandler._get_exception(500) == exceptions.ServerError
137+
assert RequestHandler._get_exception(503) == exceptions.ServiceUnavailable
138+
assert RequestHandler._get_exception(999) == exceptions.TypesenseClientError
139139

140140

141141
def test_normalize_params_with_booleans() -> None:
142142
"""Test that it correctly normalizes boolean values to strings."""
143143
parameter_dict: typing.Dict[str, str | bool] = {"key1": True, "key2": False}
144-
ApiCall.normalize_params(parameter_dict)
144+
RequestHandler.normalize_params(parameter_dict)
145145

146146
assert parameter_dict == {"key1": "true", "key2": "false"}
147147

@@ -151,13 +151,13 @@ def test_normalize_params_with_non_dict() -> None:
151151
parameter_non_dict = "string"
152152

153153
with pytest.raises(ValueError):
154-
ApiCall.normalize_params(parameter_non_dict)
154+
RequestHandler.normalize_params(parameter_non_dict)
155155

156156

157157
def test_normalize_params_with_mixed_types() -> None:
158158
"""Test that it correctly normalizes boolean values to strings."""
159159
parameter_dict = {"key1": True, "key2": False, "key3": "value", "key4": 123}
160-
ApiCall.normalize_params(parameter_dict)
160+
RequestHandler.normalize_params(parameter_dict)
161161
assert parameter_dict == {
162162
"key1": "true",
163163
"key2": "false",
@@ -169,14 +169,14 @@ def test_normalize_params_with_mixed_types() -> None:
169169
def test_normalize_params_with_empty_dict() -> None:
170170
"""Test that it correctly normalizes an empty dictionary."""
171171
parameter_dict: typing.Dict[str, str] = {}
172-
ApiCall.normalize_params(parameter_dict)
172+
RequestHandler.normalize_params(parameter_dict)
173173
assert not parameter_dict
174174

175175

176176
def test_normalize_params_with_no_booleans() -> None:
177177
"""Test that it correctly normalizes a dictionary with no boolean values."""
178178
parameter_dict = {"key1": "value", "key2": 123}
179-
ApiCall.normalize_params(parameter_dict)
179+
RequestHandler.normalize_params(parameter_dict)
180180
assert parameter_dict == {"key1": "value", "key2": 123}
181181

182182

@@ -191,7 +191,7 @@ def test_make_request_as_json(api_call: ApiCall) -> None:
191191
status_code=200,
192192
)
193193

194-
response = api_call.make_request(
194+
response = api_call._execute_request(
195195
session.get,
196196
"/test",
197197
as_json=True,
@@ -211,7 +211,7 @@ def test_make_request_as_text(api_call: ApiCall) -> None:
211211
status_code=200,
212212
)
213213

214-
response = api_call.make_request(
214+
response = api_call._execute_request(
215215
session.get,
216216
"/test",
217217
as_json=False,
@@ -387,7 +387,7 @@ def test_raise_custom_exception_with_header(
387387
)
388388

389389
with pytest.raises(exceptions.RequestMalformed) as exception:
390-
api_call.make_request(
390+
api_call._execute_request(
391391
requests.get,
392392
"/test",
393393
as_json=True,
@@ -408,7 +408,7 @@ def test_raise_custom_exception_without_header(
408408
)
409409

410410
with pytest.raises(exceptions.RequestMalformed) as exception:
411-
api_call.make_request(
411+
api_call._execute_request(
412412
requests.get,
413413
"/test",
414414
as_json=True,
@@ -456,24 +456,30 @@ def test_get_node_no_healthy_nodes(
456456
caplog: pytest.LogCaptureFixture,
457457
) -> None:
458458
"""Test that it logs a message if no healthy nodes are found."""
459-
for api_node in api_call.nodes:
459+
for api_node in api_call.node_manager.nodes:
460460
api_node.healthy = False
461461

462462
api_call.config.nearest_node.healthy = False
463463

464-
mocker.patch.object(api_call, "node_due_for_health_check", return_value=False)
464+
mocker.patch.object(
465+
api_call.node_manager,
466+
"_is_due_for_health_check",
467+
return_value=False,
468+
)
465469

466470
# Need to set the logger level to DEBUG to capture the message
467471
logger.setLevel(logging.DEBUG)
468472

469-
selected_node = api_call.get_node()
473+
selected_node = api_call.node_manager.get_node()
470474

471475
with caplog.at_level(logging.DEBUG):
472476
assert "No healthy nodes were found. Returning the next node." in caplog.text
473477

474-
assert selected_node == api_call.nodes[api_call.node_index]
478+
assert (
479+
selected_node == api_call.node_manager.nodes[api_call.node_manager.node_index]
480+
)
475481

476-
assert api_call.node_index == 0
482+
assert api_call.node_manager.node_index == 0
477483

478484

479485
def test_raises_if_no_nodes_are_healthy_with_the_last_exception(
@@ -579,3 +585,19 @@ def test_uses_nearest_node_if_present_and_healthy( # noqa: WPS213
579585
assert request_mocker.request_history[11].url == "http://nearest:8108/"
580586
assert request_mocker.request_history[12].url == "http://nearest:8108/"
581587
assert request_mocker.request_history[13].url == "http://nearest:8108/"
588+
589+
590+
def test_max_retries_no_last_exception(api_call: ApiCall) -> None:
591+
"""Test that it raises if the maximum number of retries is reached."""
592+
with pytest.raises(
593+
exceptions.TypesenseClientError,
594+
match="All nodes are unhealthy",
595+
):
596+
api_call._execute_request(
597+
requests.get,
598+
"/",
599+
as_json=True,
600+
entity_type=typing.Dict[str, str],
601+
num_retries=10,
602+
last_exception=None,
603+
)

tests/client_test.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ def test_client_init(fake_config_dict: ConfigDict) -> None:
1212
assert fake_client.config == fake_client.api_call.config
1313

1414
assert_match_object(fake_client.api_call.config, fake_client.config)
15-
assert_object_lists_match(fake_client.api_call.nodes, fake_client.config.nodes)
15+
assert_object_lists_match(
16+
fake_client.api_call.node_manager.nodes, fake_client.config.nodes
17+
)
1618
assert_match_object(
1719
fake_client.api_call.config.nearest_node,
1820
fake_client.config.nearest_node,
@@ -62,7 +64,9 @@ def test_retrieve_collection_actual(
6264

6365

6466
def test_retrieve_collection_actual_no_name(
65-
actual_client: Client, delete_all: None, create_collection: None,
67+
actual_client: Client,
68+
delete_all: None,
69+
create_collection: None,
6670
) -> None:
6771
"""Test that the client can retrieve an actual collection."""
6872
collection = actual_client.typed_collection(model=Companies)

tests/collection_test.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ def test_init(fake_api_call: ApiCall) -> None:
2323

2424
assert collection.name == "companies"
2525
assert_match_object(collection.api_call, fake_api_call)
26-
assert_object_lists_match(collection.api_call.nodes, fake_api_call.nodes)
26+
assert_object_lists_match(
27+
collection.api_call.node_manager.nodes,
28+
fake_api_call.node_manager.nodes,
29+
)
2730
assert_match_object(
2831
collection.api_call.config.nearest_node,
2932
fake_api_call.config.nearest_node,

tests/collections_test.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ def test_init(fake_api_call: ApiCall) -> None:
2222
collections = Collections(fake_api_call)
2323

2424
assert_match_object(collections.api_call, fake_api_call)
25-
assert_object_lists_match(collections.api_call.nodes, fake_api_call.nodes)
25+
assert_object_lists_match(
26+
collections.api_call.node_manager.nodes,
27+
fake_api_call.node_manager.nodes,
28+
)
2629
assert_match_object(
2730
collections.api_call.config.nearest_node,
2831
fake_api_call.config.nearest_node,
@@ -37,8 +40,8 @@ def test_get_missing_collection(fake_collections: Collections) -> None:
3740
assert collection.name == "companies"
3841
assert_match_object(collection.api_call, fake_collections.api_call)
3942
assert_object_lists_match(
40-
collection.api_call.nodes,
41-
fake_collections.api_call.nodes,
43+
collection.api_call.node_manager.nodes,
44+
fake_collections.api_call.node_manager.nodes,
4245
)
4346
assert_match_object(
4447
collection.api_call.config.nearest_node,

tests/conversation_model_test.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ def test_init(fake_api_call: ApiCall) -> None:
3131

3232
assert conversation_model.model_id == "conversation_model_id"
3333
assert_match_object(conversation_model.api_call, fake_api_call)
34-
assert_object_lists_match(conversation_model.api_call.nodes, fake_api_call.nodes)
34+
assert_object_lists_match(
35+
conversation_model.api_call.node_manager.nodes,
36+
fake_api_call.node_manager.nodes,
37+
)
3538
assert_match_object(
3639
conversation_model.api_call.config.nearest_node,
3740
fake_api_call.config.nearest_node,

tests/conversations_models_test.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ def test_init(fake_api_call: ApiCall) -> None:
2929
conversations_models = ConversationsModels(fake_api_call)
3030

3131
assert_match_object(conversations_models.api_call, fake_api_call)
32-
assert_object_lists_match(conversations_models.api_call.nodes, fake_api_call.nodes)
32+
assert_object_lists_match(
33+
conversations_models.api_call.node_manager.nodes,
34+
fake_api_call.node_manager.nodes,
35+
)
3336
assert_match_object(
3437
conversations_models.api_call.config.nearest_node,
3538
fake_api_call.config.nearest_node,
@@ -49,8 +52,8 @@ def test_get_missing_conversations_model(
4952
fake_conversations_models.api_call,
5053
)
5154
assert_object_lists_match(
52-
conversations_model.api_call.nodes,
53-
fake_conversations_models.api_call.nodes,
55+
conversations_model.api_call.node_manager.nodes,
56+
fake_conversations_models.api_call.node_manager.nodes,
5457
)
5558
assert_match_object(
5659
conversations_model.api_call.config.nearest_node,

0 commit comments

Comments
 (0)