Skip to content

Commit 2f70a2b

Browse files
committed
refactor: migrate request handler from requests to httpx with async support
replace requests library with httpx to enable both synchronous and asynchronous http operations. refactor make_request to support sync and async clients with separate implementation methods. - replace requests with httpx for http client operations - add support for both httpx.client and httpx.asyncclient - split make_request into _make_sync_request and _make_async_request - update type hints with bounds for tparams and tbody - change data parameter to content for httpx compatibility - update error handling to use httpx.decodingerror - update documentation to reflect async support
1 parent c577545 commit 2f70a2b

1 file changed

Lines changed: 127 additions & 89 deletions

File tree

src/typesense/request_handler.py

Lines changed: 127 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
This module provides functionality for handling HTTP requests in the Typesense client library.
33
44
Classes:
5-
- RequestHandler: Manages HTTP requests to the Typesense API.
5+
- RequestHandler: Manages HTTP requests to the Typesense API (supports both sync and async).
66
- SessionFunctionKwargs: Type for keyword arguments in session functions.
77
88
The RequestHandler class interacts with the Typesense API to manage HTTP requests,
99
handle authentication, and process responses. It provides methods to send requests,
10-
normalize parameters, and handle errors.
10+
normalize parameters, and handle errors. It supports both sync (httpx.Client) and async (httpx.AsyncClient) clients.
1111
1212
This module uses type hinting and is compatible with Python 3.11+ as well as earlier
1313
versions through the use of the typing_extensions library.
@@ -17,15 +17,16 @@
1717
- Supports JSON and non-JSON responses
1818
- Provides custom error handling for various HTTP status codes
1919
- Normalizes boolean parameters for API requests
20+
- Supports both sync (httpx.Client) and async (httpx.AsyncClient) HTTP clients
2021
21-
Note: This module relies on the 'requests' library for making HTTP requests.
22+
Note: This module relies on the 'httpx' library for both sync and async operations.
2223
"""
2324

2425
import json
2526
import sys
2627
from types import MappingProxyType
2728

28-
import requests
29+
import httpx
2930

3031
if sys.version_info >= (3, 11):
3132
import typing
@@ -47,8 +48,8 @@
4748
)
4849

4950
TEntityDict = typing.TypeVar("TEntityDict")
50-
TParams = typing.TypeVar("TParams")
51-
TBody = typing.TypeVar("TBody")
51+
TParams = typing.TypeVar("TParams", bound=typing.Dict[str, typing.Any])
52+
TBody = typing.TypeVar("TBody", bound=typing.Union[str, bytes])
5253

5354
_ERROR_CODE_MAP: typing.Mapping[str, typing.Type[TypesenseClientError]] = (
5455
MappingProxyType(
@@ -69,33 +70,47 @@
6970

7071
class SessionFunctionKwargs(typing.Generic[TParams, TBody], typing.TypedDict):
7172
"""
72-
Type definition for keyword arguments used in session functions.
73+
Type definition for keyword arguments used in request functions.
74+
75+
This is an internal abstraction that gets converted to httpx's request parameters.
76+
The `data` field is converted to `content` when passed to httpx.
77+
78+
Note: `verify` and `timeout` are set on the httpx client, not in request kwargs.
79+
However, we include them here for compatibility with the existing API.
7380
7481
Attributes:
7582
params (Optional[Union[TParams, None]]): Query parameters for the request.
83+
Passed as `params` to httpx.
7684
7785
data (Optional[Union[TBody, str, None]]): Body of the request.
86+
Converted to `content` (JSON string) when passed to httpx.
7887
7988
headers (Optional[Dict[str, str]]): Headers for the request.
89+
Passed as `headers` to httpx.
8090
8191
timeout (float): Timeout for the request in seconds.
92+
Set on the httpx client, not in request kwargs.
8293
8394
verify (bool): Whether to verify SSL certificates.
95+
Set on the httpx client, not in request kwargs.
8496
"""
8597

8698
params: typing.NotRequired[typing.Union[TParams, None]]
87-
data: typing.NotRequired[typing.Union[TBody, str, None]]
99+
data: typing.NotRequired[
100+
typing.Union[TBody, str, typing.Dict[str, typing.Any], None]
101+
]
102+
content: typing.NotRequired[typing.Union[TBody, str, None]]
88103
headers: typing.NotRequired[typing.Dict[str, str]]
89-
timeout: float
90-
verify: bool
104+
timeout: typing.NotRequired[float]
91105

92106

93107
class RequestHandler:
94108
"""
95-
Handles HTTP requests to the Typesense API.
109+
Handles HTTP requests to the Typesense API (supports both sync and async using httpx).
96110
97111
This class manages authentication, request sending, and response processing
98-
for interactions with the Typesense API.
112+
for interactions with the Typesense API. It can work with both sync (httpx.Client)
113+
and async (httpx.AsyncClient) HTTP clients.
99114
100115
Attributes:
101116
api_key_header_name (str): The header name for the API key.
@@ -113,109 +128,130 @@ def __init__(self, config: Configuration):
113128
"""
114129
self.config = config
115130

116-
@typing.overload
117131
def make_request(
118132
self,
119-
fn: typing.Callable[..., requests.models.Response],
133+
*,
134+
method: str,
120135
url: str,
121136
entity_type: typing.Type[TEntityDict],
122-
as_json: typing.Literal[False],
137+
as_json: typing.Union[typing.Literal[True], typing.Literal[False]] = True,
138+
client: typing.Union[httpx.Client, httpx.AsyncClient],
123139
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
124-
) -> str:
140+
) -> typing.Union[
141+
TEntityDict,
142+
str,
143+
typing.Coroutine[typing.Any, typing.Any, typing.Union[TEntityDict, str]],
144+
]:
125145
"""
126-
Make an HTTP request to the Typesense API and return the response as a string.
127-
128-
This overload is used when as_json is set to False, indicating that the response
129-
should be returned as a raw string instead of being parsed as JSON.
146+
Make an HTTP request to the Typesense API (supports both sync and async using httpx).
130147
131148
Args:
132-
fn (Callable): The HTTP method function to use (e.g., requests.get).
149+
method (str): The HTTP method (e.g., "GET", "POST", "PUT", "PATCH", "DELETE").
133150
134151
url (str): The URL to send the request to.
135152
136153
entity_type (Type[TEntityDict]): The expected type of the response entity.
137154
138-
as_json (Literal[False]): Specifies that the response should not be parsed as JSON.
155+
as_json (bool): Whether to return the response as JSON. Defaults to True.
156+
157+
client: The httpx client to use (httpx.Client for sync, httpx.AsyncClient for async).
139158
140159
kwargs: Additional keyword arguments for the request.
141160
142161
Returns:
143-
str: The raw string response from the API.
162+
Union[TEntityDict, str]: The response, either as a JSON object or a string.
163+
If using AsyncClient, returns a coroutine.
144164
145165
Raises:
146166
TypesenseClientError: If the API returns an error response.
147167
"""
168+
headers = {
169+
self.api_key_header_name: self.config.api_key,
170+
}
171+
headers.update(self.config.additional_headers)
148172

149-
@typing.overload
150-
def make_request(
173+
request_kwargs: SessionFunctionKwargs[TParams, TBody] = typing.cast(
174+
SessionFunctionKwargs[TParams, TBody],
175+
{
176+
"headers": headers,
177+
"timeout": self.config.connection_timeout_seconds,
178+
},
179+
)
180+
181+
if params := kwargs.get("params"):
182+
self.normalize_params(params)
183+
request_kwargs["params"] = params
184+
185+
if body := kwargs.get("data"):
186+
if not isinstance(body, (str, bytes)):
187+
body = json.dumps(body)
188+
request_kwargs["content"] = typing.cast(TBody, body)
189+
190+
if isinstance(client, httpx.AsyncClient):
191+
return self._make_async_request(
192+
method, url, entity_type, as_json, client, **request_kwargs
193+
)
194+
else:
195+
return self._make_sync_request(
196+
method, url, entity_type, as_json, client, **request_kwargs
197+
)
198+
199+
def _make_sync_request(
151200
self,
152-
fn: typing.Callable[..., requests.models.Response],
201+
method: str,
153202
url: str,
154203
entity_type: typing.Type[TEntityDict],
155-
as_json: typing.Literal[True],
156-
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
157-
) -> TEntityDict:
158-
"""
159-
Make an HTTP request to the Typesense API.
160-
161-
Args:
162-
fn (Callable): The HTTP method function to use (e.g., requests.get).
163-
164-
url (str): The URL to send the request to.
165-
166-
entity_type (Type[TEntityDict]): The expected type of the response entity.
167-
168-
as_json (bool): Whether to return the response as JSON. Defaults to True.
204+
as_json: bool,
205+
client: httpx.Client,
206+
**request_kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
207+
) -> typing.Union[TEntityDict, str]:
208+
"""Make a synchronous HTTP request using httpx.Client."""
209+
params: typing.Union[TParams, None] = request_kwargs.get("params")
210+
content: typing.Union[TBody, str, None] = request_kwargs.get("content")
211+
headers: typing.Dict[str, str] = request_kwargs.get("headers", {})
212+
213+
response = client.request(
214+
method,
215+
url,
216+
params=params,
217+
content=content,
218+
headers=headers,
219+
)
169220

170-
kwargs: Additional keyword arguments for the request.
221+
if response.status_code < 200 or response.status_code >= 300:
222+
error_message = self._get_error_message(response)
223+
raise self._get_exception(response.status_code)(
224+
response.status_code,
225+
error_message,
226+
)
171227

172-
Returns:
173-
TEntityDict: The response, as a JSON object.
228+
if as_json:
229+
res: TEntityDict = typing.cast(TEntityDict, response.json())
230+
return res
174231

175-
Raises:
176-
TypesenseClientError: If the API returns an error response.
177-
"""
232+
return response.text
178233

179-
def make_request(
234+
async def _make_async_request(
180235
self,
181-
fn: typing.Callable[..., requests.models.Response],
236+
method: str,
182237
url: str,
183238
entity_type: typing.Type[TEntityDict],
184-
as_json: typing.Union[typing.Literal[True], typing.Literal[False]] = True,
185-
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
239+
as_json: bool,
240+
client: httpx.AsyncClient,
241+
**request_kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
186242
) -> typing.Union[TEntityDict, str]:
187-
"""
188-
Make an HTTP request to the Typesense API.
189-
190-
Args:
191-
fn (Callable): The HTTP method function to use (e.g., requests.get).
192-
193-
url (str): The URL to send the request to.
194-
195-
entity_type (Type[TEntityDict]): The expected type of the response entity.
196-
197-
as_json (bool): Whether to return the response as JSON. Defaults to True.
198-
199-
kwargs: Additional keyword arguments for the request.
200-
201-
Returns:
202-
Union[TEntityDict, str]: The response, either as a JSON object or a string.
203-
204-
Raises:
205-
TypesenseClientError: If the API returns an error response.
206-
"""
207-
headers = {
208-
self.api_key_header_name: self.config.api_key,
209-
}
210-
headers.update(self.config.additional_headers)
211-
212-
kwargs.setdefault("headers", {}).update(headers)
213-
kwargs.setdefault("timeout", self.config.connection_timeout_seconds)
214-
kwargs.setdefault("verify", self.config.verify)
215-
if kwargs.get("data") and not isinstance(kwargs["data"], (str, bytes)):
216-
kwargs["data"] = json.dumps(kwargs["data"])
217-
218-
response = fn(url, **kwargs)
243+
"""Make an asynchronous HTTP request using httpx.AsyncClient."""
244+
params: typing.Union[TParams, None] = request_kwargs.get("params")
245+
content: typing.Union[TBody, str, None] = request_kwargs.get("content")
246+
headers: typing.Dict[str, str] = request_kwargs.get("headers", {})
247+
248+
response = await client.request(
249+
method,
250+
url,
251+
params=params,
252+
content=content,
253+
headers=headers,
254+
)
219255

220256
if response.status_code < 200 or response.status_code >= 300:
221257
error_message = self._get_error_message(response)
@@ -225,18 +261,18 @@ def make_request(
225261
)
226262

227263
if as_json:
228-
res: TEntityDict = response.json()
264+
res: TEntityDict = typing.cast(TEntityDict, response.json())
229265
return res
230266

231267
return response.text
232268

233269
@staticmethod
234-
def normalize_params(params: TParams) -> None:
270+
def normalize_params(params: typing.Dict[str, typing.Any]) -> None:
235271
"""
236272
Normalize boolean parameters in the request.
237273
238274
Args:
239-
params (TParams): The parameters to normalize.
275+
params (Dict[str, Any]): The parameters to normalize.
240276
241277
Raises:
242278
ValueError: If params is not a dictionary.
@@ -248,12 +284,12 @@ def normalize_params(params: TParams) -> None:
248284
params[key] = str(parameter_value).lower()
249285

250286
@staticmethod
251-
def _get_error_message(response: requests.Response) -> str:
287+
def _get_error_message(response: httpx.Response) -> str:
252288
"""
253289
Extract the error message from an API response.
254290
255291
Args:
256-
response (requests.Response): The API response.
292+
response (httpx.Response): The API response.
257293
258294
Returns:
259295
str: The extracted error message or a default message.
@@ -262,9 +298,11 @@ def _get_error_message(response: requests.Response) -> str:
262298
if content_type.startswith("application/json"):
263299
try:
264300
return typing.cast(str, response.json().get("message", "API error."))
265-
except requests.exceptions.JSONDecodeError:
301+
except (json.JSONDecodeError, httpx.DecodingError):
266302
return f"API error: Invalid JSON response: {response.text}"
267-
return "API error."
303+
if response.text:
304+
return f"API error. {response.text}"
305+
return f"Unknown API error. Full Response: {response}"
268306

269307
@staticmethod
270308
def _get_exception(http_code: int) -> typing.Type[TypesenseClientError]:

0 commit comments

Comments
 (0)