Repository navigation
Description
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 ofasync for sse in iteratorat[DONE]. Itsfinallycloses onlyresponse, notiterator._iter_eventsiteratesself._decoder.aiter_bytes(self.response.aiter_bytes()). An interruptedasync fordoes not close the iterator it was looping over, so neither of those two is closed.close()closes onlyresponse, notself._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
withblock, 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_resourcesandtests/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
Asutosa commentedon Oct 24, 2026
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.
hemv-857 commentedon Oct 27, 2026
Root-cause analysis and a complete patch, posted here per CONTRIBUTING (PRs are limited to collaborators).
Where the generators leak
src/openai/_streaming.pyopens a nested chain of async generators and never closes it:AsyncStream._iter_eventsiteratesself._decoder.aiter_bytes(self.response.aiter_bytes()). An interruptedasync fordoes not close the iterator it was looping over, so both the decoder iterator and the response byte iterator stay suspended.__stream__breaks at[DONE]; itsfinallycloses onlyresponse, notiterator.close()closes onlyresponse, notself._iterator.__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 whereRuntimeError('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:Validation
tests/test_streaming.py: consume a stream with an early exit, then assert every generator in the chain reportsag_frame == Noneafterawait 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 --checkon 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.