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
16 changes: 16 additions & 0 deletions src/openai/_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ def __stream__(self) -> Iterator[_T]:
try:
for sse in iterator:
if sse.data.startswith("[DONE]"):
# Drain any remaining bytes so that h11 can parse the
# HTTP/1.1 chunked terminator (`0\r\n\r\n`) before
# response.close() is called. Without this, h11's
# their_state is still SEND_RESPONSE, so httpcore takes
# the "destroy" branch instead of returning the connection
# to the pool, causing a spurious TCP FIN.
for _ in iterator:
pass
Comment on lines +70 to +71

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 Don't block after the DONE sentinel

This now consumes the stream until HTTP EOF before returning from the [DONE] branch. For SSE-compatible endpoints or proxies that emit the documented data: [DONE] terminator (src/openai/types/completion_create_params.py:181-184) but leave the event-stream connection open, delay EOF, or truncate while cleanup is being read, the user's final iteration will hang until httpx's read timeout or raise after a complete stream; before this change it returned immediately on the sentinel. The post-DONE drain should be best-effort or bounded so connection-pool cleanup does not change stream-completion semantics.

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 @@ -171,6 +179,14 @@ async def __stream__(self) -> AsyncIterator[_T]:
try:
async for sse in iterator:
if sse.data.startswith("[DONE]"):
# Drain any remaining bytes so that h11 can parse the
# HTTP/1.1 chunked terminator (`0\r\n\r\n`) before
# response.aclose() is called. Without this, h11's
# their_state is still SEND_RESPONSE, so httpcore takes
# the "destroy" branch instead of returning the connection
# to the pool, causing a spurious TCP FIN.
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
61 changes: 61 additions & 0 deletions tests/test_streaming.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from typing import Iterator, AsyncIterator
from unittest.mock import patch, MagicMock, AsyncMock

import httpx
Comment on lines 3 to 6
import pytest
Expand Down Expand Up @@ -216,6 +217,66 @@ def body() -> Iterator[bytes]:
assert sse.json() == {"content": "известни"}


@pytest.mark.asyncio
@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
async def test_done_drains_remaining_bytes_before_close(
sync: bool, client: OpenAI, async_client: AsyncOpenAI
) -> None:
"""Regression test for #3440.

When Stream/__stream__ sees [DONE] it must drain the underlying byte
iterator before calling response.close() / response.aclose(). Without
the drain, h11's their_state is still SEND_RESPONSE at the time of
close(), so httpcore destroys the connection instead of returning it to
the pool, producing a spurious TCP FIN.
"""
drain_calls: list[str] = []

def body() -> Iterator[bytes]:
yield b'data: {"id":"1"}\n\n'
yield b"data: [DONE]\n\n"
# bytes that arrive after [DONE] — the HTTP/1.1 chunked terminator
# would appear here in a real response
drain_calls.append("trailing-bytes-yielded")
yield b""

if sync:
response = httpx.Response(200, content=body())
close_calls: list[str] = []
original_close = response.close

def tracking_close() -> None:
close_calls.append("closed")
original_close()

response.close = tracking_close # type: ignore[method-assign]
stream = Stream(cast_to=object, client=client, response=response)
list(stream.__stream__())

# The trailing bytes must have been consumed *before* close() was called.
assert drain_calls == ["trailing-bytes-yielded"], (
"trailing bytes were not drained before close()"
)
else:
Comment on lines +256 to +260
async_body = to_aiter(body())
response = httpx.Response(200, content=async_body)
aclose_calls: list[str] = []
original_aclose = response.aclose

async def tracking_aclose() -> None:
aclose_calls.append("aclosed")
await original_aclose()

response.aclose = tracking_aclose # type: ignore[method-assign]
stream = AsyncStream(cast_to=object, client=async_client, response=response)
async for _ in stream.__stream__():
pass

assert drain_calls == ["trailing-bytes-yielded"], (
"trailing bytes were not drained before aclose()"
)


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