Skip to content

AsyncClient.stream() iteration blocks the event loop #85

Description

@thomasht86

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions