Skip to content

AsyncStream leaves its SSE event iterators to the garbage collector after [DONE] or close() #4174

Open

Description

@fancyboi999

Confirm this is an issue with the Python library and not an underlying OpenAI API

  • This is an issue with the Python library

Describe the bug

AsyncStream never closes the async generators it opens. After a stream is read to [DONE], or after the caller stops early and calls await stream.close(), these generators are still suspended and are only closed when the garbage collector reaches them:

  • AsyncStream._iter_events
  • the decoder iterator (SSEDecoder.aiter_bytes)
  • the response.aiter_bytes() iterator it wraps
  • after an early exit, the AsyncStream.__aiter__ wrapper

Where this happens in src/openai/_streaming.py (v3.37.0):

  • __stream__ breaks out of async for sse in iterator at [DONE]. Its finally closes only response, not iterator.
  • _iter_events iterates self._decoder.aiter_bytes(self.response.aiter_bytes()). An interrupted async for does not close the iterator it was looping over, so neither of those two is closed.
  • close() closes only response, not self._iterator.

asyncio's GC finalizer starts a separate aclose() task for each unreachable async generator. A nested chain that becomes unreachable at the same time is therefore closed concurrently. In production (Python 3.13, httpx2 2.13.1) this logs:

Task exception was never retrieved
future: <Task finished name='Task-1220' coro=<<async_generator_athrow without __name__>()> exception=RuntimeError('aclose(): asynchronous generator is already running')>

That generator is never closed, so its cleanup does not run. The httpx2 half of the race, inside httpx2's own nested iterators, is pydantic/httpx2#1280, fixed by pydantic/httpx2#1281. Even with that PR, the openai-owned generators above are still left to the GC.

This is separate from #3440 / #4040 (connection reuse after [DONE]) and from #3561 (server-side state after an aborted stream).

To Reproduce

The script below is self-contained: a local SSE server, no API key.

import asyncio, gc, sys, collections, json
import openai

CHUNK = {"id": "x", "object": "chat.completion.chunk", "created": 0, "model": "m",
         "choices": [{"index": 0, "delta": {"content": "hi"}, "finish_reason": None}]}

async def handle(reader, writer):
    while (await reader.readline()) not in (b"\r\n", b""):
        pass
    body = "".join(f"data: {json.dumps(CHUNK)}\n\n" for _ in range(3)) + "data: [DONE]\n\n"
    writer.write(b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: %d\r\n\r\n"
                 % len(body) + body.encode())
    await writer.drain()
    writer.close()

async def main():
    loop = asyncio.get_running_loop()
    original, finalized = loop._asyncgen_finalizer_hook, collections.Counter()
    def hook(agen):
        finalized[agen.__qualname__] += 1
        original(agen)
    sys.set_asyncgen_hooks(firstiter=loop._asyncgen_firstiter_hook, finalizer=hook)
    server = await asyncio.start_server(handle, "127.0.0.1", 0)
    client = openai.AsyncOpenAI(api_key="x", base_url=f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/v1")
    for case in ("full read", "early exit + close()"):
        stream = await client.chat.completions.create(model="m", messages=[{"role": "user", "content": "x"}], stream=True)
        async for _ in stream:
            if case != "full read":
                break
        await stream.close()
        del stream
        gc.collect()
        await asyncio.sleep(0.2)
        print(f"{case}: finalized by GC = {dict(finalized) or 'none'}")
        finalized.clear()
    await client.close()
    server.close()

asyncio.run(main())

Output with openai 3.37.0 and httpx2 2.16.0 on Python 3.13.16:

full read: finalized by GC = {'AsyncStream._iter_events': 1, 'SSEDecoder.aiter_bytes': 1, 'Response.aiter_bytes': 1, 'PoolByteStream.__aiter__': 1}
early exit + close(): finalized by GC = {'AsyncStream.__aiter__': 1}

With the change below:

full read: finalized by GC = {'PoolByteStream.__aiter__': 1}      # httpx2-internal, see pydantic/httpx2#1281
early exit + close(): finalized by GC = {'PoolByteStream.__aiter__': 1}

With the change below and httpx2 at pydantic/httpx2#1281, the only generator left is the httpcore2-internal HTTP11ConnectionByteStream.__aiter__. On its own it cannot race.

Suggested fix

Close what each layer opens, with one cleanup policy:

  • Try every resource.
  • An exception already propagating, or leaving a with block, wins over a cleanup error.
  • Cancellation is never dropped.
  • Normal closure does not hide cleanup errors.

The same applies to the Chat, Responses and agent stream wrappers. The full diff, with regression tests that fail on main and pass with the fix, is here: fancyboi999#1

Checks run against main (718aee9):

  • Whole suite except tests/api_resources and tests/lib/live: 10201 passed (+41 new) and no new failures.
  • ruff, mypy and pyright 1.1.414 are clean.

OS

Linux / macOS

Python version

3.13.16 (also reproduced on 3.12.11)

Library version

openai 3.37.0 (also 3.29.0), httpx2 2.16.0 (also 2.13.1)

Activity

  1. Asutosa commented on Oct 24, 2026

    @Asutosa

    I investigated the async SSE iterator cleanup issue and pushed a proposed fix to my fork.

    Changes: Added explicit async iterator cleanup and preserved the direct iterator returned by AsyncStream.__aiter__().

    Validation: python -m pytest tests/test_streaming.py -q — 64 passed, 34 skipped.

    Commit: Asutosa@e0a065f

    I noticed the related fix already linked here and would appreciate maintainer feedback on whether this approach addresses the remaining cleanup cases.

  2. hemv-857 commented on Oct 27, 2026

    @hemv-857

    Root-cause analysis and a complete patch, posted here per CONTRIBUTING (PRs are limited to collaborators).

    Where the generators leak

    src/openai/_streaming.py opens a nested chain of async generators and never closes it:

    1. AsyncStream._iter_events iterates self._decoder.aiter_bytes(self.response.aiter_bytes()). An interrupted async for does not close the iterator it was looping over, so both the decoder iterator and the response byte iterator stay suspended.
    2. __stream__ breaks at [DONE]; its finally closes only response, not iterator.
    3. close() closes only response, not self._iterator.
    4. After an early exit, the __aiter__ wrapper is also left suspended.

    asyncio's GC finalizer then starts a separate aclose() task per unreachable async generator, and a chain that becomes unreachable together is closed concurrently, which is where RuntimeError('aclose(): asynchronous generator is already running') comes from. The httpx-side half of the race is tracked in pydantic/httpx2#1280 / #1281; the openai-owned generators above are independent of that and still need explicit cleanup.

    Patch

    Four touchpoints in _streaming.py, closing inner-to-outer:

    # _iter_events: bind the nested generators and close them in the finally
    async def _iter_events(self) -> AsyncIterator[ServerSentEvent]:
        response_iter = self.response.aiter_bytes()
        decoded = self._decoder.aiter_bytes(response_iter)
        try:
            async for sse in decoded:
                yield sse
        except timeout_exceptions() as err:
            raise APITimeoutError(request=self.response.request) from err
        except request_exceptions() as err:
            raise APIConnectionError(request=self.response.request) from err
        finally:
            await decoded.aclose()
            await response_iter.aclose()
    
    # __aiter__: close the wrapper when the consumer stops iterating
    async def __aiter__(self) -> AsyncIterator[_T]:
        try:
            async for item in self._iterator:
                yield item
        finally:
            await self._iterator.aclose()
    
    # __stream__ finally: close the iterator, then the response
    finally:
        try:
            await iterator.aclose()
        finally:
            await response.aclose()
    
    # close(): close the iterator, then the response
    async def close(self) -> None:
        try:
            await self._iterator.aclose()
        finally:
            await self.response.aclose()

    Validation

    • New regression test in tests/test_streaming.py: consume a stream with an early exit, then assert every generator in the chain reports ag_frame == None after await stream.close() (i.e. all closed, none left for the GC finalizer).
    • python -m pytest tests/test_streaming.py -q (excluding the aiohttp lane, extra not installed): 60 passed.
    • ruff check / ruff format --check on both files: clean.

    Reference implementation with the full test: https://github.com/hemv-857/openai-python/tree/fix/4174-async-stream-close-nested-generators (commit fb9ea7c). Happy to reshape it to match maintainer preferences, or to fold in anything @Asutosa's approach covers that this one does not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions