Skip to content

Commit 9c2e2ae

Browse files
committed
refactor: migrate api_call from requests to httpx and add async support
- replace requests with httpx in api_call class - add async_api_call class with httpx.asyncclient support - migrate tests from requests_mock to respx
1 parent fb8780d commit 9c2e2ae

4 files changed

Lines changed: 852 additions & 188 deletions

File tree

src/typesense/api_call.py

Lines changed: 158 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@
3434

3535
import sys
3636

37-
import requests
37+
import httpx
3838

39+
if sys.version_info >= (3, 11):
40+
import typing
41+
else:
42+
import typing_extensions as typing
3943
from typesense.configuration import Configuration, Node
4044
from typesense.exceptions import (
4145
HTTPStatus0Error,
@@ -44,36 +48,138 @@
4448
TypesenseClientError,
4549
)
4650
from typesense.node_manager import NodeManager
47-
from typesense.request_handler import RequestHandler, SessionFunctionKwargs
51+
from typesense.request_handler import (
52+
RequestHandler,
53+
)
54+
55+
TEntityDict = typing.TypeVar("TEntityDict")
56+
TParams = typing.TypeVar("TParams", bound=typing.Dict[str, typing.Any])
57+
TBody = typing.TypeVar(
58+
"TBody", bound=typing.Union[str, bytes, typing.Mapping[str, typing.Any]]
59+
)
60+
61+
62+
class SessionFunctionKwargs(typing.Generic[TParams, TBody], typing.TypedDict):
63+
"""
64+
Type definition for keyword arguments used in request functions.
65+
66+
This is an internal abstraction that gets converted to httpx's request parameters.
67+
The `data` field is converted to `content` when passed to httpx.
68+
69+
Note: `verify` and `timeout` are set on the httpx client, not in request kwargs.
70+
However, we include them here for compatibility with the existing API.
71+
72+
Attributes:
73+
params (Optional[Union[TParams, None]]): Query parameters for the request.
74+
Passed as `params` to httpx.
75+
76+
data (Optional[Union[TBody, str, None]]): Body of the request.
77+
Converted to `content` (JSON string) when passed to httpx.
78+
79+
headers (Optional[Dict[str, str]]): Headers for the request.
80+
Passed as `headers` to httpx.
81+
82+
timeout (float): Timeout for the request in seconds.
83+
Set on the httpx client, not in request kwargs.
84+
85+
verify (bool): Whether to verify SSL certificates.
86+
Set on the httpx client, not in request kwargs.
87+
"""
88+
89+
params: typing.NotRequired[typing.Union[TParams, None]]
90+
data: typing.NotRequired[
91+
typing.Union[TBody, str, typing.Dict[str, typing.Any], None]
92+
]
93+
content: typing.NotRequired[typing.Union[TBody, str, None]]
94+
headers: typing.NotRequired[typing.Dict[str, str]]
95+
timeout: typing.NotRequired[float]
96+
4897

4998
if sys.version_info >= (3, 11):
5099
import typing
51100
else:
52101
import typing_extensions as typing
53102

54-
session = requests.sessions.Session()
55-
TParams = typing.TypeVar("TParams")
56-
TBody = typing.TypeVar("TBody")
57-
TEntityDict = typing.TypeVar("TEntityDict")
103+
104+
class ApiCallProtocol(typing.Protocol):
105+
"""
106+
Protocol defining the interface for API call classes.
107+
108+
This protocol ensures that both sync (ApiCall) and async (AsyncApiCall)
109+
implementations provide the same interface, allowing resource classes
110+
to work with either implementation.
111+
"""
112+
113+
config: Configuration
114+
node_manager: NodeManager
115+
request_handler: RequestHandler
116+
117+
def get(
118+
self,
119+
endpoint: str,
120+
entity_type: typing.Type[TEntityDict],
121+
as_json: typing.Union[typing.Literal[True], typing.Literal[False]] = True,
122+
params: typing.Union[TParams, None] = None,
123+
) -> typing.Union[TEntityDict, str]:
124+
"""Execute a GET request to the Typesense API."""
125+
...
126+
127+
def post(
128+
self,
129+
endpoint: str,
130+
entity_type: typing.Type[TEntityDict],
131+
as_json: typing.Union[typing.Literal[True], typing.Literal[False]] = True,
132+
params: typing.Union[TParams, None] = None,
133+
body: typing.Union[TBody, None] = None,
134+
) -> typing.Union[str, TEntityDict]:
135+
"""Execute a POST request to the Typesense API."""
136+
...
137+
138+
def put(
139+
self,
140+
endpoint: str,
141+
entity_type: typing.Type[TEntityDict],
142+
body: TBody,
143+
params: typing.Union[TParams, None] = None,
144+
) -> TEntityDict:
145+
"""Execute a PUT request to the Typesense API."""
146+
...
147+
148+
def patch(
149+
self,
150+
endpoint: str,
151+
entity_type: typing.Type[TEntityDict],
152+
body: TBody,
153+
params: typing.Union[TParams, None] = None,
154+
) -> TEntityDict:
155+
"""Execute a PATCH request to the Typesense API."""
156+
...
157+
158+
def delete(
159+
self,
160+
endpoint: str,
161+
entity_type: typing.Type[TEntityDict],
162+
params: typing.Union[TParams, None] = None,
163+
) -> TEntityDict:
164+
"""Execute a DELETE request to the Typesense API."""
165+
...
58166

59167

60168
_SERVER_ERRORS: typing.Final[
61169
typing.Tuple[
62-
typing.Type[requests.exceptions.Timeout],
63-
typing.Type[requests.exceptions.ConnectionError],
64-
typing.Type[requests.exceptions.HTTPError],
65-
typing.Type[requests.exceptions.RequestException],
66-
typing.Type[requests.exceptions.SSLError],
170+
typing.Type[httpx.TimeoutException],
171+
typing.Type[httpx.ConnectError],
172+
typing.Type[httpx.HTTPError],
173+
typing.Type[httpx.RequestError],
67174
typing.Type[HTTPStatus0Error],
68175
typing.Type[ServerError],
69176
typing.Type[ServiceUnavailable],
70177
]
71178
] = (
72-
requests.exceptions.Timeout,
73-
requests.exceptions.ConnectionError,
74-
requests.exceptions.HTTPError,
75-
requests.exceptions.RequestException,
76-
requests.exceptions.SSLError,
179+
httpx.TimeoutException,
180+
httpx.ConnectError,
181+
httpx.HTTPError,
182+
httpx.RequestError,
77183
HTTPStatus0Error,
78184
ServerError,
79185
ServiceUnavailable,
@@ -103,6 +209,10 @@ def __init__(self, config: Configuration):
103209
self.config = config
104210
self.node_manager = NodeManager(config)
105211
self.request_handler = RequestHandler(config)
212+
self._client = httpx.Client(
213+
timeout=config.connection_timeout_seconds,
214+
verify=config.verify,
215+
)
106216

107217
@typing.overload
108218
def get(
@@ -166,7 +276,7 @@ def get(
166276
Union[TEntityDict, str]: The response, either as a JSON object or a string.
167277
"""
168278
return self._execute_request(
169-
session.get,
279+
"GET",
170280
endpoint,
171281
entity_type,
172282
as_json,
@@ -238,7 +348,7 @@ def post(
238348
Union[TEntityDict, str]: The response, either as a JSON object or a string.
239349
"""
240350
return self._execute_request(
241-
session.post,
351+
"POST",
242352
endpoint,
243353
entity_type,
244354
as_json,
@@ -265,7 +375,7 @@ def put(
265375
EntityDict: The response, as a JSON object.
266376
"""
267377
return self._execute_request(
268-
session.put,
378+
"PUT",
269379
endpoint,
270380
entity_type,
271381
as_json=True,
@@ -292,7 +402,7 @@ def patch(
292402
EntityDict: The response, as a JSON object.
293403
"""
294404
return self._execute_request(
295-
session.patch,
405+
"PATCH",
296406
endpoint,
297407
entity_type,
298408
as_json=True,
@@ -318,7 +428,7 @@ def delete(
318428
EntityDict: The response, as a JSON object.
319429
"""
320430
return self._execute_request(
321-
session.delete,
431+
"DELETE",
322432
endpoint,
323433
entity_type,
324434
as_json=True,
@@ -328,13 +438,13 @@ def delete(
328438
@typing.overload
329439
def _execute_request(
330440
self,
331-
fn: typing.Callable[..., requests.models.Response],
441+
method: str,
332442
endpoint: str,
333443
entity_type: typing.Type[TEntityDict],
334444
as_json: typing.Literal[True],
335445
last_exception: typing.Union[None, Exception] = None,
336446
num_retries: int = 0,
337-
**kwargs: SessionFunctionKwargs[TParams, TBody],
447+
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
338448
) -> TEntityDict:
339449
"""
340450
Execute a request to the Typesense API with retry logic.
@@ -367,13 +477,13 @@ def _execute_request(
367477
@typing.overload
368478
def _execute_request(
369479
self,
370-
fn: typing.Callable[..., requests.models.Response],
480+
method: str,
371481
endpoint: str,
372482
entity_type: typing.Type[TEntityDict],
373483
as_json: typing.Literal[False],
374484
last_exception: typing.Union[None, Exception] = None,
375485
num_retries: int = 0,
376-
**kwargs: SessionFunctionKwargs[TParams, TBody],
486+
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
377487
) -> str:
378488
"""
379489
Execute a request to the Typesense API with retry logic.
@@ -405,13 +515,13 @@ def _execute_request(
405515

406516
def _execute_request(
407517
self,
408-
fn: typing.Callable[..., requests.models.Response],
518+
method: str,
409519
endpoint: str,
410520
entity_type: typing.Type[TEntityDict],
411521
as_json: typing.Union[typing.Literal[True], typing.Literal[False]] = True,
412522
last_exception: typing.Union[None, Exception] = None,
413523
num_retries: int = 0,
414-
**kwargs: SessionFunctionKwargs[TParams, TBody],
524+
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
415525
) -> typing.Union[TEntityDict, str]:
416526
"""
417527
Execute a request to the Typesense API with retry logic.
@@ -420,7 +530,7 @@ def _execute_request(
420530
node selection, error handling, and retries.
421531
422532
Args:
423-
fn (Callable): The HTTP method function to use (e.g., session.get).
533+
method (str): The HTTP method to use (e.g., "GET", "POST").
424534
425535
endpoint (str): The API endpoint to call.
426536
@@ -449,7 +559,7 @@ def _execute_request(
449559

450560
try:
451561
return self._make_request_and_process_response(
452-
fn,
562+
method,
453563
url,
454564
entity_type,
455565
as_json,
@@ -458,7 +568,7 @@ def _execute_request(
458568
except _SERVER_ERRORS as server_error:
459569
self.node_manager.set_node_health(node, is_healthy=False)
460570
return self._execute_request(
461-
fn,
571+
method,
462572
endpoint,
463573
entity_type,
464574
as_json,
@@ -469,18 +579,19 @@ def _execute_request(
469579

470580
def _make_request_and_process_response(
471581
self,
472-
fn: typing.Callable[..., requests.models.Response],
582+
method: str,
473583
url: str,
474584
entity_type: typing.Type[TEntityDict],
475585
as_json: bool,
476-
**kwargs: SessionFunctionKwargs[TParams, TBody],
586+
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
477587
) -> typing.Union[TEntityDict, str]:
478588
"""Make the API request and process the response."""
479589
request_response = self.request_handler.make_request(
480-
fn=fn,
590+
method=method,
481591
url=url,
482592
as_json=as_json,
483593
entity_type=entity_type,
594+
client=self._client,
484595
**kwargs,
485596
)
486597
self.node_manager.set_node_health(self.node_manager.get_node(), is_healthy=True)
@@ -493,12 +604,23 @@ def _make_request_and_process_response(
493604
def _prepare_request_params(
494605
self,
495606
endpoint: str,
496-
**kwargs: SessionFunctionKwargs[TParams, TBody],
607+
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
497608
) -> typing.Tuple[Node, str, SessionFunctionKwargs[TParams, TBody]]:
609+
"""
610+
Prepare request parameters including node selection and URL construction.
611+
612+
Args:
613+
endpoint: The API endpoint path.
614+
**kwargs: Request parameters following SessionFunctionKwargs structure.
615+
616+
Returns:
617+
Tuple of (node, full_url, kwargs_dict) where kwargs_dict contains
618+
the request parameters as a regular dict for further processing.
619+
"""
498620
node = self.node_manager.get_node()
499621
url = node.url() + endpoint
500622

501-
if kwargs.get("params"):
502-
self.request_handler.normalize_params(kwargs["params"])
623+
if params := kwargs.get("params"):
624+
self.request_handler.normalize_params(params)
503625

504626
return node, url, kwargs

0 commit comments

Comments
 (0)