Skip to content

Commit e8fda6f

Browse files
committed
fix: drain remaining bytes after [DONE] before closing response (#3440)
When the SSE decoder encounters [DONE], it breaks out of the event loop immediately. However, the underlying httpx response iterator may not have reached EOF yet — the chunked terminator (0\r\n\r\n) can still be in flight. Calling response.close() at this point sends a TCP FIN while the server is still transmitting, which prevents h11 from advancing to the DONE state and causes connection pool degradation and proxy errors. The fix drains remaining events from the existing iterator (not a new response.iter_bytes() call, which would raise StreamConsumed) after [DONE] is encountered, in both sync and async paths. This allows the response to reach EOF naturally before close. Regression tests verify that trailing events after [DONE] are consumed and the response is fully closed in both sync and async modes. Closes #3440
1 parent b77076d commit e8fda6f

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

src/openai/_streaming.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ def __stream__(self) -> Iterator[_T]:
6868
try:
6969
for sse in iterator:
7070
if sse.data.startswith("[DONE]"):
71+
# Drain remaining events from the existing iterator so the
72+
# underlying response.iter_bytes() reaches EOF, allowing
73+
# h11 to advance to DONE state before close. Without this,
74+
# response.close() sends TCP FIN while the chunked terminator
75+
# (0\r\n\r\n) is still in flight, causing connection pool
76+
# degradation and proxy errors. (#3440)
77+
#
78+
# We must drain through `iterator` (not start a new
79+
# `self.response.iter_bytes()`) because httpx only allows
80+
# one active iterator at a time — a second call raises
81+
# `httpx.StreamConsumed`.
82+
for _ in iterator:
83+
pass
7184
break
7285

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

188214
# we have to special case the Assistants `thread.` events since we won't have an "event" key in the data

tests/test_streaming.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,3 +438,37 @@ def make_event_iterator(
438438
return AsyncStream(
439439
cast_to=object, client=async_client, response=httpx2.Response(200, content=to_aiter(content))
440440
)._iter_events()
441+
442+
443+
@pytest.mark.asyncio
444+
@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
445+
async def test_drain_after_done_consumes_trailing_events(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None:
446+
"""After [DONE], the stream should drain remaining events from the iterator
447+
so the underlying response reaches EOF. Regression test for #3440."""
448+
449+
def body() -> Iterator[bytes]:
450+
yield b"event: completion\n"
451+
yield b'data: {"foo":true}\n'
452+
yield b"\n"
453+
yield b"data: [DONE]\n"
454+
yield b"\n"
455+
# Trailing event after [DONE] — should be consumed by the drain.
456+
yield b"event: trailing\n"
457+
yield b'data: {"bar":false}\n'
458+
yield b"\n"
459+
460+
if sync:
461+
response = httpx2.Response(200, content=body())
462+
stream = Stream(cast_to=object, client=client, response=response)
463+
results: list[object] = list(stream)
464+
assert len(results) == 1
465+
assert results[0] == {"foo": True}
466+
# The response should be fully consumed (not just half-read).
467+
assert response.is_closed
468+
else:
469+
response = httpx2.Response(200, content=to_aiter(body()))
470+
stream = AsyncStream(cast_to=object, client=async_client, response=response)
471+
results = [item async for item in stream] # type: ignore[reportUnknownVariableType]
472+
assert len(results) == 1
473+
assert results[0] == {"foo": True}
474+
assert response.is_closed

0 commit comments

Comments
 (0)