Skip to content

Commit a9e240a

Browse files
committed
feat: accept a user-supplied httpx or httpx2 client (#146)
1 parent 94590e2 commit a9e240a

11 files changed

Lines changed: 515 additions & 124 deletions

File tree

‎README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,33 @@ if __name__ == "__main__":
4242

4343
See `examples/async_collection_operations.py` for a fuller async walkthrough.
4444

45+
## Using httpx2
46+
47+
The client sends requests with [httpx](https://www.python-httpx.org/) by default. On Python 3.10+ you can pass an [httpx2](https://github.com/pydantic/httpx2) client instead. httpx2 is Pydantic's maintained continuation of httpx, and it fixes a connection pool leak in httpcore ([encode/httpcore#1093](https://github.com/encode/httpcore/issues/1093)) that can leave an `AsyncClient` failing every request with `PoolTimeout` under load.
48+
49+
```
50+
$ pip install "typesense[httpx2]"
51+
```
52+
53+
```python
54+
import httpx2
55+
import typesense
56+
57+
http_client = httpx2.AsyncClient(
58+
timeout=httpx2.Timeout(2.0),
59+
limits=httpx2.Limits(max_connections=100, max_keepalive_connections=20),
60+
)
61+
client = typesense.AsyncClient(
62+
{
63+
"api_key": "abcd",
64+
"nodes": [{"host": "localhost", "port": "8108", "protocol": "http"}],
65+
},
66+
http_client=http_client,
67+
)
68+
```
69+
70+
`typesense.Client` takes an `httpx2.Client` the same way. The connection pool settings in the config (`pool_timeout_seconds`, `max_connections`, `max_keepalive_connections`) only apply to the default client, so set them on your own client instead. The Typesense client does not close a client you pass in.
71+
4572
## Compatibility
4673

4774
| Typesense Server | typesense-python |

‎pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ dependencies = [
2727
]
2828
dynamic = ["version"]
2929

30+
[project.optional-dependencies]
31+
# Maintained continuation of httpx; fixes the httpcore pool leak in encode/httpcore#1093.
32+
httpx2 = ["httpx2>=2.6.0; python_version >= '3.10'"]
33+
3034
[project.urls]
3135
Documentation = "https://typesense.org/"
3236
Source = "https://github.com/typesense/typesense-python"

‎src/typesense/async_/api_call.py‎

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
ServiceUnavailable,
5252
TypesenseClientError,
5353
)
54+
from typesense.http_backend import ASYNC_CLIENT_TYPES, AsyncClientType, backend_errors
5455
from typesense.node_manager import NodeManager
5556
from typesense.request_handler import RequestHandler
5657

@@ -116,38 +117,23 @@ class SessionFunctionKwargs(typing.Generic[TParams, TBody], typing.TypedDict):
116117
},
117118
)
118119

119-
_SERVER_ERRORS: typing.Final[
120-
typing.Tuple[
121-
typing.Type[httpx.TimeoutException],
122-
typing.Type[httpx.ConnectError],
123-
typing.Type[httpx.HTTPError],
124-
typing.Type[httpx.RequestError],
125-
typing.Type[HTTPStatus0Error],
126-
typing.Type[ServerError],
127-
typing.Type[ServiceUnavailable],
128-
]
129-
] = (
130-
httpx.TimeoutException,
131-
httpx.ConnectError,
132-
httpx.HTTPError,
133-
httpx.RequestError,
120+
_SERVER_ERRORS: typing.Final[typing.Tuple[typing.Type[Exception], ...]] = (
121+
*backend_errors("TimeoutException"),
122+
*backend_errors("ConnectError"),
123+
*backend_errors("HTTPError"),
124+
*backend_errors("RequestError"),
134125
HTTPStatus0Error,
135126
ServerError,
136127
ServiceUnavailable,
137128
)
138129

139-
_CLIENT_ERRORS: typing.Final[
140-
typing.Tuple[
141-
typing.Type[httpx.PoolTimeout],
142-
typing.Type[httpx.LocalProtocolError],
143-
typing.Type[httpx.DecodingError],
144-
typing.Type[httpx.TooManyRedirects],
145-
]
146-
] = (
147-
httpx.PoolTimeout,
148-
httpx.LocalProtocolError,
149-
httpx.DecodingError,
150-
httpx.TooManyRedirects,
130+
# Raised by httpx inside the client, so they say nothing about the node's
131+
# health. They subclass entries of _SERVER_ERRORS and must be caught first.
132+
_CLIENT_ERRORS: typing.Final[typing.Tuple[typing.Type[Exception], ...]] = (
133+
*backend_errors("PoolTimeout"),
134+
*backend_errors("LocalProtocolError"),
135+
*backend_errors("DecodingError"),
136+
*backend_errors("TooManyRedirects"),
151137
)
152138

153139

@@ -161,19 +147,42 @@ class AsyncApiCall:
161147
Attributes:
162148
config (Configuration): The configuration object for the Typesense client.
163149
node_manager (NodeManager): Manages the nodes in the Typesense cluster.
164-
_client (httpx.AsyncClient): The httpx async client for making requests.
150+
_client (httpx.AsyncClient | httpx2.AsyncClient): The async client for
151+
making requests.
165152
"""
166153

167-
def __init__(self, config: Configuration):
154+
def __init__(
155+
self,
156+
config: Configuration,
157+
http_client: typing.Optional[AsyncClientType] = None,
158+
):
168159
"""
169160
Initialize the AsyncApiCall instance.
170161
171162
Args:
172163
config (Configuration): The configuration object for the Typesense client.
164+
http_client (httpx.AsyncClient | httpx2.AsyncClient, optional): A client
165+
to send requests with instead of the default httpx client. The
166+
connection pool settings in ``config`` are not applied to it, and it
167+
is not closed by ``aclose``.
168+
169+
Raises:
170+
TypeError: If ``http_client`` is not an httpx or httpx2 async client.
173171
"""
174172
self.config = config
175173
self.node_manager = NodeManager(config)
176174
self.request_handler = RequestHandler(config)
175+
self._concurrency_limit = AsyncConcurrencyLimit(
176+
config.max_concurrent_requests,
177+
)
178+
self._owns_client = http_client is None
179+
if http_client is not None:
180+
if not isinstance(http_client, ASYNC_CLIENT_TYPES):
181+
raise TypeError(
182+
"`http_client` must be an httpx.AsyncClient or httpx2.AsyncClient.",
183+
)
184+
self._client: AsyncClientType = http_client
185+
return
177186
self._client = httpx.AsyncClient(
178187
timeout=httpx.Timeout(
179188
config.connection_timeout_seconds,
@@ -184,9 +193,6 @@ def __init__(self, config: Configuration):
184193
max_keepalive_connections=config.max_keepalive_connections,
185194
),
186195
)
187-
self._concurrency_limit = AsyncConcurrencyLimit(
188-
config.max_concurrent_requests,
189-
)
190196

191197
async def __aenter__(self) -> "AsyncApiCall":
192198
"""Async context manager entry."""
@@ -199,11 +205,12 @@ async def __aexit__(
199205
exc_tb: typing.Optional[TracebackType],
200206
) -> None:
201207
"""Async context manager exit."""
202-
await self._client.aclose()
208+
await self.aclose()
203209

204210
async def aclose(self) -> None:
205-
"""Close the httpx client."""
206-
await self._client.aclose()
211+
"""Close the httpx client, unless it was passed in by the caller."""
212+
if self._owns_client:
213+
await self._client.aclose()
207214

208215
@typing.overload
209216
async def get(

‎src/typesense/async_/client.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
from .stopwords import AsyncStopwords
5656
from .synonym_sets import AsyncSynonymSets
5757
from typesense.configuration import ConfigDict, Configuration
58+
from typesense.http_backend import AsyncClientType
5859

5960
TDoc = typing.TypeVar("TDoc", bound=DocumentSchema)
6061

@@ -86,14 +87,24 @@ class AsyncClient:
8687
conversations_models (ConversationsModels): Instance for managing conversation models.
8788
"""
8889

89-
def __init__(self, config_dict: ConfigDict) -> None:
90+
def __init__(
91+
self,
92+
config_dict: ConfigDict,
93+
http_client: typing.Optional[AsyncClientType] = None,
94+
) -> None:
9095
"""
9196
Initialize the Client instance.
9297
9398
Args:
9499
config_dict (ConfigDict):
95100
A dictionary containing the configuration for the Typesense client.
96101
102+
http_client (httpx.AsyncClient | httpx2.AsyncClient, optional):
103+
A client to send requests with instead of the default httpx client,
104+
e.g. an ``httpx2.AsyncClient`` (``pip install typesense[httpx2]``).
105+
The connection pool settings in ``config_dict`` are not applied to
106+
it, and the Typesense client does not close it.
107+
97108
Example:
98109
>>> config = {
99110
... "api_key": "your_api_key",
@@ -105,7 +116,7 @@ def __init__(self, config_dict: ConfigDict) -> None:
105116
>>> client = Client(config)
106117
"""
107118
self.config = Configuration(config_dict)
108-
self.api_call = AsyncApiCall(self.config)
119+
self.api_call = AsyncApiCall(self.config, http_client)
109120
self.collections: AsyncCollections[DocumentSchema] = AsyncCollections(
110121
self.api_call
111122
)

‎src/typesense/http_backend.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""
2+
HTTP backends the Typesense client can send requests with.
3+
4+
The client builds an ``httpx`` client by default. Users can pass their own client
5+
instead, either from ``httpx`` or from ``httpx2`` (install ``typesense[httpx2]``,
6+
Python 3.10+). ``httpx2`` is Pydantic's maintained continuation of ``httpx``. It
7+
fixes a connection pool leak in ``httpcore`` (encode/httpcore#1093) that can leave
8+
an async client failing every request with ``PoolTimeout``.
9+
10+
``httpx2`` has the same API as ``httpx`` but its own classes, so every
11+
``isinstance`` check and ``except`` clause has to cover both packages. This module
12+
builds those type tuples once, including ``httpx2`` only when it is installed.
13+
"""
14+
15+
import importlib
16+
import sys
17+
from types import ModuleType
18+
19+
import httpx
20+
21+
if sys.version_info >= (3, 11):
22+
import typing
23+
else:
24+
import typing_extensions as typing
25+
26+
if typing.TYPE_CHECKING:
27+
import httpx2 # noqa: F401 (used in the string annotations in this module)
28+
29+
30+
def _import_httpx2() -> typing.Optional[ModuleType]:
31+
"""Return the ``httpx2`` module, or ``None`` when it is not installed."""
32+
# ``import_module`` is typed as returning a module whether or not httpx2 is
33+
# installed; a plain ``import`` is ``Any`` to mypy when it is missing (Python 3.9).
34+
try:
35+
return importlib.import_module("httpx2")
36+
except ImportError:
37+
return None
38+
39+
40+
_httpx2 = _import_httpx2()
41+
42+
_BACKENDS: typing.Final[typing.Tuple[ModuleType, ...]] = tuple(
43+
backend for backend in (httpx, _httpx2) if backend is not None
44+
)
45+
46+
SyncClientType = typing.Union[httpx.Client, "httpx2.Client"]
47+
AsyncClientType = typing.Union[httpx.AsyncClient, "httpx2.AsyncClient"]
48+
ResponseType = typing.Union[httpx.Response, "httpx2.Response"]
49+
50+
51+
def backend_errors(name: str) -> typing.Tuple[typing.Type[Exception], ...]:
52+
"""
53+
Return the exception class called ``name`` from every installed backend.
54+
55+
Args:
56+
name (str): The exception name shared by ``httpx`` and ``httpx2``.
57+
58+
Returns:
59+
Tuple[Type[Exception], ...]: The matching classes, for ``except`` clauses.
60+
"""
61+
return tuple(getattr(backend, name) for backend in _BACKENDS)
62+
63+
64+
# Declared precisely for type checkers so ``isinstance`` narrows to the client
65+
# unions above; at runtime they only contain the backends that are installed.
66+
if typing.TYPE_CHECKING:
67+
CLIENT_TYPES: typing.Tuple[
68+
typing.Type[httpx.Client],
69+
typing.Type["httpx2.Client"],
70+
]
71+
ASYNC_CLIENT_TYPES: typing.Tuple[
72+
typing.Type[httpx.AsyncClient],
73+
typing.Type["httpx2.AsyncClient"],
74+
]
75+
else:
76+
CLIENT_TYPES = tuple(backend.Client for backend in _BACKENDS)
77+
ASYNC_CLIENT_TYPES = tuple(backend.AsyncClient for backend in _BACKENDS)

0 commit comments

Comments
 (0)