Summary
AsyncClient.stream() yields a StreamingResponse whose only iteration methods (iter_bytes, iter_text, iter_lines) are synchronous. Each __next__ calls into Rust and does RUNTIME.block_on(...) on the event loop thread, so the entire asyncio application stalls for the duration of the stream.
Reproduction
A heartbeat task ticking every 50ms, running concurrently with an async stream of a 1.5s response:
async def heartbeat(stop):
n = 0
while not stop.is_set():
await asyncio.sleep(0.05); n += 1
return n
async with httpr.AsyncClient() as c:
async with c.stream("GET", url) as r:
for chunk in r.iter_bytes():
pass
httpr async stream took 1.51s; heartbeat ticks: 1 (a free loop would tick ~30)
One tick in 1.5 seconds — the loop is blocked almost the whole time.
Impact
Streaming's main use in async code today is SSE and LLM token streaming, where a response is held open for seconds to minutes. For that whole window an AsyncClient blocks every other task on the loop: no concurrent requests, no timers, no health-check endpoint. This undercuts both the "first-class async" and "streaming" bullets in the README.
The README does note that iteration is synchronous, but frames it as an API-shape caveat rather than "this blocks your event loop".
Expected
async for chunk in response.aiter_bytes(): — iteration that yields control back to the loop between chunks.
Proposed fix
Pure Python, no Rust changes needed. In httpr/__init__.py, add aiter_bytes(), aiter_text() and aiter_lines() async generators that dispatch each step of the underlying sync iterator onto the client's existing self._executor (the ThreadPoolExecutor added for max_concurrency), via the existing _run_sync_asyncio helper. Roughly:
async def _aiter(self, sync_iter):
sentinel = object()
while True:
chunk = await self._run_sync_asyncio(next, sync_iter, sentinel)
if chunk is sentinel:
return
yield chunk
Then attach these to the StreamingResponse handed out by AsyncClient.stream() (a small wrapper class holding a reference to the client is probably cleanest), add the stubs to httpr/httpr.pyi, and update the README's streaming note.
Keeping the existing sync iter_* methods working is fine — this is additive.
Suggested tests
- The heartbeat test above, asserting the loop keeps ticking (e.g. >10 ticks over a ~1.5s stream).
aiter_lines() over a chunked SSE-style response returns the expected lines.
- Two concurrent
aiter_bytes() streams overlap rather than serialize.
Size
~1-2 hours including stubs and tests.
Summary
AsyncClient.stream()yields aStreamingResponsewhose only iteration methods (iter_bytes,iter_text,iter_lines) are synchronous. Each__next__calls into Rust and doesRUNTIME.block_on(...)on the event loop thread, so the entire asyncio application stalls for the duration of the stream.Reproduction
A heartbeat task ticking every 50ms, running concurrently with an async stream of a 1.5s response:
One tick in 1.5 seconds — the loop is blocked almost the whole time.
Impact
Streaming's main use in async code today is SSE and LLM token streaming, where a response is held open for seconds to minutes. For that whole window an
AsyncClientblocks every other task on the loop: no concurrent requests, no timers, no health-check endpoint. This undercuts both the "first-class async" and "streaming" bullets in the README.The README does note that iteration is synchronous, but frames it as an API-shape caveat rather than "this blocks your event loop".
Expected
async for chunk in response.aiter_bytes():— iteration that yields control back to the loop between chunks.Proposed fix
Pure Python, no Rust changes needed. In
httpr/__init__.py, addaiter_bytes(),aiter_text()andaiter_lines()async generators that dispatch each step of the underlying sync iterator onto the client's existingself._executor(theThreadPoolExecutoradded formax_concurrency), via the existing_run_sync_asynciohelper. Roughly:Then attach these to the
StreamingResponsehanded out byAsyncClient.stream()(a small wrapper class holding a reference to the client is probably cleanest), add the stubs tohttpr/httpr.pyi, and update the README's streaming note.Keeping the existing sync
iter_*methods working is fine — this is additive.Suggested tests
aiter_lines()over a chunked SSE-style response returns the expected lines.aiter_bytes()streams overlap rather than serialize.Size
~1-2 hours including stubs and tests.