Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/openai/_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ def __stream__(self) -> Iterator[_T]:
try:
for sse in iterator:
if sse.data.startswith("[DONE]"):
# Drain remaining events from the existing iterator so the
# underlying response.iter_bytes() reaches EOF, allowing
# h11 to advance to DONE state before close. Without this,
# response.close() sends TCP FIN while the chunked terminator
# (0\r\n\r\n) is still in flight, causing connection pool
# degradation and proxy errors. (#3440)
#
# We must drain through `iterator` (not start a new
# `self.response.iter_bytes()`) because httpx only allows
# one active iterator at a time — a second call raises
# `httpx.StreamConsumed`.
for _ in iterator:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not propagate drain failures after [DONE]

When an upstream/proxy has already delivered the [DONE] sentinel but then stalls or closes before EOF/chunk termination, this new drain keeps reading and any ReadTimeout/RemoteProtocolError from the best-effort cleanup now escapes after the stream is logically complete. Before this change the stream ended at [DONE] and the finally block just closed the response, so users would not see a failure after receiving the complete stream; the async drain has the same issue. Consider making post-[DONE] draining best-effort so cleanup failures do not replace successful stream completion.

Useful? React with 👍 / 👎.

pass
Comment on lines +76 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drain raw bytes without decoding trailing SSE data

When a custom or misbehaving SSE endpoint sends [DONE] followed by a large unterminated body line, consuming iterator routes all trailing bytes through _SSELineDecoder, whose buffer is intentionally unbounded, and then copies and UTF-8-decodes that buffer at EOF. A logically completed stream can therefore consume memory proportional to the entire discarded body, potentially with several simultaneous copies; the async loop has the same behavior. Preserve the active raw byte iterator and discard its chunks after [DONE] without passing them through the SSE decoder.

Useful? React with 👍 / 👎.

break

# we have to special case the Assistants `thread.` events since we won't have an "event" key in the data
Expand Down Expand Up @@ -172,6 +185,19 @@ async def __stream__(self) -> AsyncIterator[_T]:
try:
async for sse in iterator:
if sse.data.startswith("[DONE]"):
# Drain remaining events from the existing iterator so the
# underlying response.aiter_bytes() reaches EOF, allowing
# h11 to advance to DONE state before close. Without this,
# response.aclose() sends TCP FIN while the chunked terminator
# (0\r\n\r\n) is still in flight, causing connection pool
# degradation and proxy errors. (#3440)
#
# We must drain through `iterator` (not start a new
# `self.response.aiter_bytes()`) because httpx only allows
# one active iterator at a time — a second call raises
# `httpx.StreamConsumed`.
async for _ in iterator:
pass
break

# we have to special case the Assistants `thread.` events since we won't have an "event" key in the data
Expand Down
34 changes: 34 additions & 0 deletions tests/test_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,37 @@ def make_event_iterator(
return AsyncStream(
cast_to=object, client=async_client, response=httpx2.Response(200, content=to_aiter(content))
)._iter_events()


@pytest.mark.asyncio
@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
async def test_drain_after_done_consumes_trailing_events(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None:
"""After [DONE], the stream should drain remaining events from the iterator
so the underlying response reaches EOF. Regression test for #3440."""

def body() -> Iterator[bytes]:
yield b"event: completion\n"
yield b'data: {"foo":true}\n'
yield b"\n"
yield b"data: [DONE]\n"
yield b"\n"
# Trailing event after [DONE] — should be consumed by the drain.
yield b"event: trailing\n"
yield b'data: {"bar":false}\n'
yield b"\n"

if sync:
response = httpx2.Response(200, content=body())
stream = Stream(cast_to=object, client=client, response=response)
results: list[object] = list(stream)
assert len(results) == 1
assert results[0] == {"foo": True}
# The response should be fully consumed (not just half-read).
assert response.is_closed
else:
response = httpx2.Response(200, content=to_aiter(body()))
stream = AsyncStream(cast_to=object, client=async_client, response=response)
results = [item async for item in stream] # type: ignore[reportUnknownVariableType]
assert len(results) == 1
assert results[0] == {"foo": True}
assert response.is_closed