Skip to content

Commit ea5ec1d

Browse files
lesnik512claude
andcommitted
feat(client): AsyncClient.stream() context manager
Adds AsyncClient.stream(method, url, **kwargs) as a @contextlib.asynccontextmanager method on the client. Mirrors httpx2.AsyncClient.stream() but auto-raises StatusError subclasses on 4xx/5xx (consistent with client.get/post/etc.) with body pre-read so exc.response.content is accessible. Bypasses the middleware chain (v1 design decision — revisit if user feedback warrants). Uses the shared _httpx2_exception_mapper and _raise_on_status_error helpers extracted in the earlier refactor commit, so dispatch logic stays in lockstep with _terminal. Body consumption errors during 'async for chunk in response.aiter_bytes()' propagate through the yield and get mapped to httpware exceptions consistently. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 98681e0 commit ea5ec1d

2 files changed

Lines changed: 397 additions & 0 deletions

File tree

src/httpware/client.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,66 @@ async def request( # noqa: PLR0913 — mirrors httpx2 per-method signatures
688688
response_model=response_model,
689689
)
690690

691+
@contextlib.asynccontextmanager
692+
async def stream( # noqa: PLR0913, C901 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
693+
self,
694+
method: str,
695+
url: str,
696+
*,
697+
params: typing.Any | None = None,
698+
headers: typing.Any | None = None,
699+
cookies: typing.Any | None = None,
700+
timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
701+
extensions: typing.Any | None = None,
702+
json: typing.Any | None = None,
703+
content: typing.Any | None = None,
704+
data: typing.Any | None = None,
705+
files: typing.Any | None = None,
706+
) -> AsyncIterator[httpx2.Response]:
707+
"""Stream an HTTP response. Bypasses the middleware chain.
708+
709+
Yields an httpx2.Response; consume the body via response.aiter_bytes(),
710+
response.aiter_text(), response.aiter_lines(), or response.aiter_raw().
711+
The body is NOT pre-read for 2xx/3xx (streaming preserved); the response
712+
is closed when the context exits.
713+
714+
Bypasses the middleware chain (no Retry, no Bulkhead, no user-installed
715+
middleware) for v1 — see planning/specs/2026-06-05-streaming-design.md.
716+
717+
Auto-raises StatusError subclasses on 4xx/5xx (NotFoundError,
718+
ServiceUnavailableError, etc.) — consistent with client.get()/post()/etc.
719+
On error the response body is pre-read so exc.response.content is
720+
accessible. You lose the streaming property on errors; rare in practice.
721+
722+
Maps httpx2 exceptions raised during the request OR body consumption to
723+
httpware exceptions via _httpx2_exception_mapper.
724+
"""
725+
kwargs: dict[str, typing.Any] = {}
726+
if params is not None:
727+
kwargs["params"] = params
728+
if headers is not None:
729+
kwargs["headers"] = headers
730+
if cookies is not None:
731+
kwargs["cookies"] = cookies
732+
if timeout is not httpx2.USE_CLIENT_DEFAULT:
733+
kwargs["timeout"] = timeout
734+
if extensions is not None:
735+
kwargs["extensions"] = extensions
736+
if json is not None:
737+
kwargs["json"] = json
738+
if content is not None:
739+
kwargs["content"] = content
740+
if data is not None:
741+
kwargs["data"] = data
742+
if files is not None:
743+
kwargs["files"] = files
744+
745+
async with _httpx2_exception_mapper(), self._httpx2_client.stream(method, url, **kwargs) as response:
746+
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
747+
await response.aread() # pre-read body so exc.response.content works
748+
_raise_on_status_error(response)
749+
yield response
750+
691751
async def __aenter__(self) -> typing.Self:
692752
"""Enter the async context manager; return self."""
693753
return self

0 commit comments

Comments
 (0)