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 @@ -61,6 +61,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
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 @@ -171,6 +184,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
51 changes: 51 additions & 0 deletions tests/test_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,57 @@ def body() -> Iterator[bytes]:
assert sse.json() == {"content": "известни"}


@pytest.mark.asyncio
@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
async def test_stream_drains_after_done(
sync: bool,
client: OpenAI,
async_client: AsyncOpenAI,
) -> None:
"""Regression test for #3440: after [DONE], remaining events should be drained.

The fix drains the existing iterator (not a new response.iter_bytes() call)
so that httpx doesn't raise StreamConsumed and the response reaches EOF
before close, preventing premature TCP FIN.

We track whether the body generator reached its final sentinel to verify
that the drain actually consumed the trailing event — not just that the
response was closed (which happens in the finally block regardless).
"""

consumed_after_done: list[bool] = []

def body() -> Iterator[bytes]:
yield b'data: {"foo":true}\n'
yield b"\n"
yield b"data: [DONE]\n"
yield b"\n"
# Extra data after [DONE] that should be consumed by the drain
yield b'data: {"trailing":true}\n'
yield b"\n"
# Sentinel: only reached if the drain consumed all trailing events
consumed_after_done.append(True)

if sync:
response = httpx.Response(200, content=body())
stream = Stream(cast_to=object, client=client, response=response)
# Consume the full stream — the drain should consume the trailing event
for _ in stream:
pass
assert response.is_closed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Assert the trailing body is actually consumed

This assertion is true even if the new drain loop is removed, because Stream.__stream__/AsyncStream.__stream__ always closes the response in their finally after breaking on [DONE]; closing the httpx.Response can discard unread generator content. As a result, a regression that stops draining the trailing data: {"trailing":true} event still passes, despite the test claiming to verify full consumption. Track the body iterator with a sentinel/list and assert EOF was reached.

Useful? React with 👍 / 👎.

else:
response = httpx.Response(200, content=to_aiter(body()))
stream = AsyncStream(cast_to=object, client=async_client, response=response)
async for _ in stream:
pass
assert response.is_closed

# The sentinel is only appended if the body generator was fully consumed.
# Without the drain loop, the generator is garbage-collected when the
# response closes, so this assertion fails — proving the drain works.
assert consumed_after_done == [True]


async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]:
for chunk in iter:
yield chunk
Expand Down