Skip to content

Commit 133f6fa

Browse files
committed
fix(streaming): open streams with client.stream so httpx2 type-checks
1 parent 23a1838 commit 133f6fa

5 files changed

Lines changed: 71 additions & 62 deletions

File tree

‎src/typesense/async_/api_call.py‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
import asyncio
3535
import sys
36+
from contextlib import AsyncExitStack
3637
from types import MappingProxyType, TracebackType
3738

3839
import httpx
@@ -54,6 +55,7 @@
5455
from typesense.http_backend import (
5556
ASYNC_CLIENT_TYPES,
5657
AsyncClientType,
58+
ResponseType,
5759
backend_errors,
5860
verify_option,
5961
)
@@ -648,38 +650,41 @@ async def _open_stream(
648650
headers = request_kwargs.get("headers", {})
649651
headers["Accept"] = "text/event-stream"
650652
timeout = self._client.timeout
651-
request = self._client.build_request(
652-
method,
653-
url,
654-
params=typing.cast(
655-
typing.Optional[_QueryParams],
656-
request_kwargs.get("params"),
657-
),
658-
content=request_kwargs.get("content"),
659-
headers=headers,
660-
timeout=(
661-
timeout.connect,
662-
self.config.stream_read_timeout_seconds,
663-
timeout.write,
664-
timeout.pool,
665-
),
653+
# Annotated so httpx and httpx2 responses unify as ``ResponseType``.
654+
response_context: typing.AsyncContextManager[ResponseType] = (
655+
self._client.stream(
656+
method,
657+
url,
658+
params=typing.cast(
659+
typing.Optional[_QueryParams],
660+
request_kwargs.get("params"),
661+
),
662+
content=request_kwargs.get("content"),
663+
headers=headers,
664+
timeout=(
665+
timeout.connect,
666+
self.config.stream_read_timeout_seconds,
667+
timeout.write,
668+
timeout.pool,
669+
),
670+
)
666671
)
667672

673+
# Owns the concurrency slot and the response until the stream is closed.
674+
exit_stack = AsyncExitStack()
668675
await self._concurrency_limit.acquire()
676+
exit_stack.callback(self._concurrency_limit.release)
669677
try:
670-
response = await self._client.send(request, stream=True)
678+
response = await exit_stack.enter_async_context(response_context)
671679
if response.status_code < 200 or response.status_code >= 300:
672-
try:
673-
await response.aread()
674-
finally:
675-
await response.aclose()
680+
await response.aread()
676681
self.request_handler.raise_for_status(response)
677682
except BaseException:
678-
self._concurrency_limit.release()
683+
await exit_stack.aclose()
679684
raise
680685

681686
self.node_manager.set_node_health(node, is_healthy=True)
682-
return AsyncSearchStream(response, self._concurrency_limit.release)
687+
return AsyncSearchStream(response, exit_stack)
683688

684689
def _prepare_request_params(
685690
self,

‎src/typesense/async_/stream.py‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""
1515

1616
import sys
17+
from contextlib import AsyncExitStack
1718
from types import TracebackType
1819

1920
from typesense.exceptions import TypesenseClientError
@@ -46,19 +47,19 @@ class AsyncSearchStream(typing.Generic[TFinal]):
4647
def __init__(
4748
self,
4849
response: ResponseType,
49-
on_close: typing.Callable[[], None],
50+
exit_stack: AsyncExitStack,
5051
) -> None:
5152
"""
5253
Initialize the stream.
5354
5455
Args:
5556
response (httpx.Response | httpx2.Response): A successful response
5657
opened with ``stream=True``.
57-
on_close (Callable[[], None]): Called once when the stream is closed,
58-
to release the request's concurrency slot.
58+
exit_stack (AsyncExitStack): Closes the response and releases the
59+
request's concurrency slot when the stream is closed.
5960
"""
6061
self.response = response
61-
self._on_close = on_close
62+
self._exit_stack = exit_stack
6263
self._closed = False
6364
self._final: typing.Optional[TFinal] = None
6465
self._decoder = SSEDecoder()
@@ -112,14 +113,11 @@ async def aclose(self) -> None:
112113
await self._close_response()
113114

114115
async def _close_response(self) -> None:
115-
"""Close the response once, then run ``on_close``."""
116+
"""Close the response and release its concurrency slot, once."""
116117
if self._closed:
117118
return
118119
self._closed = True
119-
try:
120-
await self.response.aclose()
121-
finally:
122-
self._on_close()
120+
await self._exit_stack.aclose()
123121

124122
async def _iter_chunks(self) -> typing.AsyncGenerator[MessageChunk, None]:
125123
"""Yield the answer pieces and keep the final search response."""

‎src/typesense/sync/api_call.py‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
import time
3535
import sys
36+
from contextlib import ExitStack
3637
from types import MappingProxyType, TracebackType
3738

3839
import httpx
@@ -54,6 +55,7 @@
5455
from typesense.http_backend import (
5556
CLIENT_TYPES,
5657
SyncClientType,
58+
ResponseType,
5759
backend_errors,
5860
verify_option,
5961
)
@@ -648,38 +650,41 @@ def _open_stream(
648650
headers = request_kwargs.get("headers", {})
649651
headers["Accept"] = "text/event-stream"
650652
timeout = self._client.timeout
651-
request = self._client.build_request(
652-
method,
653-
url,
654-
params=typing.cast(
655-
typing.Optional[_QueryParams],
656-
request_kwargs.get("params"),
657-
),
658-
content=request_kwargs.get("content"),
659-
headers=headers,
660-
timeout=(
661-
timeout.connect,
662-
self.config.stream_read_timeout_seconds,
663-
timeout.write,
664-
timeout.pool,
665-
),
653+
# Annotated so httpx and httpx2 responses unify as ``ResponseType``.
654+
response_context: typing.ContextManager[ResponseType] = (
655+
self._client.stream(
656+
method,
657+
url,
658+
params=typing.cast(
659+
typing.Optional[_QueryParams],
660+
request_kwargs.get("params"),
661+
),
662+
content=request_kwargs.get("content"),
663+
headers=headers,
664+
timeout=(
665+
timeout.connect,
666+
self.config.stream_read_timeout_seconds,
667+
timeout.write,
668+
timeout.pool,
669+
),
670+
)
666671
)
667672

673+
# Owns the concurrency slot and the response until the stream is closed.
674+
exit_stack = ExitStack()
668675
self._concurrency_limit.acquire()
676+
exit_stack.callback(self._concurrency_limit.release)
669677
try:
670-
response = self._client.send(request, stream=True)
678+
response = exit_stack.enter_context(response_context)
671679
if response.status_code < 200 or response.status_code >= 300:
672-
try:
673-
response.read()
674-
finally:
675-
response.close()
680+
response.read()
676681
self.request_handler.raise_for_status(response)
677682
except BaseException:
678-
self._concurrency_limit.release()
683+
exit_stack.close()
679684
raise
680685

681686
self.node_manager.set_node_health(node, is_healthy=True)
682-
return SearchStream(response, self._concurrency_limit.release)
687+
return SearchStream(response, exit_stack)
683688

684689
def _prepare_request_params(
685690
self,

‎src/typesense/sync/stream.py‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""
1515

1616
import sys
17+
from contextlib import ExitStack
1718
from types import TracebackType
1819

1920
from typesense.exceptions import TypesenseClientError
@@ -46,19 +47,19 @@ class SearchStream(typing.Generic[TFinal]):
4647
def __init__(
4748
self,
4849
response: ResponseType,
49-
on_close: typing.Callable[[], None],
50+
exit_stack: ExitStack,
5051
) -> None:
5152
"""
5253
Initialize the stream.
5354
5455
Args:
5556
response (httpx.Response | httpx2.Response): A successful response
5657
opened with ``stream=True``.
57-
on_close (Callable[[], None]): Called once when the stream is closed,
58-
to release the request's concurrency slot.
58+
exit_stack (ExitStack): Closes the response and releases the
59+
request's concurrency slot when the stream is closed.
5960
"""
6061
self.response = response
61-
self._on_close = on_close
62+
self._exit_stack = exit_stack
6263
self._closed = False
6364
self._final: typing.Optional[TFinal] = None
6465
self._decoder = SSEDecoder()
@@ -112,14 +113,11 @@ def close(self) -> None:
112113
self._close_response()
113114

114115
def _close_response(self) -> None:
115-
"""Close the response once, then run ``on_close``."""
116+
"""Close the response and release its concurrency slot, once."""
116117
if self._closed:
117118
return
118119
self._closed = True
119-
try:
120-
self.response.close()
121-
finally:
122-
self._on_close()
120+
self._exit_stack.close()
123121

124122
def _iter_chunks(self) -> typing.Generator[MessageChunk, None, None]:
125123
"""Yield the answer pieces and keep the final search response."""

‎utils/run-unasync.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ def collect_class_replacements(source_dir: Path) -> dict[str, str]:
3535
# Defined in the shared ``typesense.sse`` module, outside async_.
3636
replacements["aiter_events"] = "iter_events"
3737
replacements["aiter_bytes"] = "iter_bytes"
38+
replacements["AsyncExitStack"] = "ExitStack"
39+
replacements["AsyncContextManager"] = "ContextManager"
40+
replacements["enter_async_context"] = "enter_context"
3841
# ``AsyncGenerator`` takes two type arguments, but ``Generator`` needs three
3942
# before Python 3.13.
4043
replacements["Generator[MessageChunk, None]"] = "Generator[MessageChunk, None, None]"

0 commit comments

Comments
 (0)