Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d4b47e7
fix(streaming): ensure remaining body is consumed after [DONE] in Str…
vrs-darkness Aug 5, 2026
d59c409
fix(streaming): handle UnicodeError during stream draining in Stream …
vrs-darkness Aug 5, 2026
91d9c26
Merge branch 'main' into fix/drain-stream-after-done-cleanup
vrs-darkness Aug 10, 2026
bc04f6d
Merge branch 'main' into fix/drain-stream-after-done-cleanup
vrs-darkness Aug 11, 2026
73dae37
Merge branch 'main' into fix/drain-stream-after-done-cleanup
vrs-darkness Aug 14, 2026
114ec57
refactor(streaming): replace consume functions with drain functions f…
vrs-darkness Aug 14, 2026
8a8d02b
refactor(streaming): update iterator handling for byte streams
vrs-darkness Aug 14, 2026
7e528cb
refactor(streaming): enhance iterator draining with timeout handling
vrs-darkness Aug 14, 2026
0316faa
fix(streaming): add missing newline for code clarity
vrs-darkness Aug 14, 2026
f27ac40
refactor(streaming): improve byte iterator handling in Stream classes
vrs-darkness Aug 14, 2026
54493eb
refactor(streaming): streamline error handling in iterator draining
vrs-darkness Aug 14, 2026
cf37030
refactor(streaming): update comments for iterator draining
vrs-darkness Aug 14, 2026
b94d8a3
refactor(streaming): enhance iterator draining with optional handling
vrs-darkness Aug 14, 2026
34248fa
refactor(streaming): enhance synchronous iterator draining with threa…
vrs-darkness Aug 14, 2026
4e25c1e
refactor(streaming): remove unused import in _streams.py
vrs-darkness Aug 14, 2026
c1dc31b
refactor(streaming): integrate anyio for async iterator draining
vrs-darkness Aug 14, 2026
d9b5411
refactor(streaming): enhance iterator draining with response handling
vrs-darkness Aug 14, 2026
ea219c4
refactor(streaming): improve handling of iterator draining post [DONE…
vrs-darkness Aug 14, 2026
10adc43
refactor(streaming): streamline function signatures for iterator drai…
vrs-darkness Aug 14, 2026
5055298
refactor(streaming): improve thread handling in synchronous iterator …
vrs-darkness Aug 14, 2026
58d222d
refactor(streaming): simplify iterator draining logic and enhance res…
vrs-darkness Aug 14, 2026
990ed6f
refactor(streaming): simplify byte iterator handling in Stream class
vrs-darkness Aug 14, 2026
33fdf50
fix(streaming): ensure proper draining behavior on early exit from st…
vrs-darkness Aug 14, 2026
cab1db4
fix(streaming): guard async drain with _done_seen flag and improve te…
vrs-darkness Aug 14, 2026
f1bac43
Merge branch 'main' into fix/drain-stream-after-done-cleanup
vrs-darkness Aug 17, 2026
0f7c63f
Merge branch 'main' into fix/drain-stream-after-done-cleanup
vrs-darkness Aug 21, 2026
5f4c047
Merge branch 'main' into fix/drain-stream-after-done-cleanup
vrs-darkness Aug 22, 2026
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
14 changes: 13 additions & 1 deletion src/openai/_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import httpx

from ._utils import is_mapping, extract_type_var_from_base
from ._utils import is_mapping, consume_sync_iterator, consume_async_iterator, extract_type_var_from_base
from ._exceptions import APIError

if TYPE_CHECKING:
Expand Down Expand Up @@ -61,6 +61,12 @@ def __stream__(self) -> Iterator[_T]:
try:
for sse in iterator:
if sse.data.startswith("[DONE]"):
# Best-effort drain so close() can return the connection to the pool.
# [DONE] is already terminal for callers; drain failures must not fail the stream.
try:
consume_sync_iterator(iterator)
except (httpx.HTTPError, UnicodeError):
Comment thread
vrs-darkness marked this conversation as resolved.
Outdated
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 +177,12 @@ async def __stream__(self) -> AsyncIterator[_T]:
try:
async for sse in iterator:
if sse.data.startswith("[DONE]"):
# Best-effort drain so aclose() can return the connection to the pool.
# [DONE] is already terminal for callers; drain failures must not fail the stream.
try:
await consume_async_iterator(iterator)
except (httpx.HTTPError, UnicodeError):
pass
break

# we have to special case the Assistants `thread.` events since we won't have an "event" key in the data
Expand Down
84 changes: 81 additions & 3 deletions tests/test_streaming.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from __future__ import annotations

from typing import Iterator, AsyncIterator
from collections.abc import AsyncIterator, Iterator

import httpx
import pytest

from openai import OpenAI, AsyncOpenAI
from openai._streaming import Stream, AsyncStream, ServerSentEvent
from openai import AsyncOpenAI, OpenAI
from openai._streaming import AsyncStream, ServerSentEvent, Stream


@pytest.mark.asyncio
Expand Down Expand Up @@ -216,6 +216,84 @@ 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_body(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None:
"""After [DONE], remaining body bytes must be consumed so close() can reuse the connection."""
exhausted = False

def body() -> Iterator[bytes]:
nonlocal exhausted
yield b'data: {"foo":true}\n\n'
yield b"data: [DONE]\n\n"
yield b": trailing comment after done\n\n"
exhausted = True

response = httpx.Response(200, content=body() if sync else to_aiter(body()))
Comment thread
vrs-darkness marked this conversation as resolved.
Outdated

if sync:
stream: Stream[object] | AsyncStream[object] = Stream(cast_to=object, client=client, response=response)
chunks = list(stream)
else:
stream = AsyncStream(cast_to=object, client=async_client, response=response)
chunks = [chunk async for chunk in stream]

assert chunks == [{"foo": True}]
assert exhausted is True
assert response.is_closed is True


@pytest.mark.asyncio
@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
async def test_drain_failure_after_done_preserves_result(
sync: bool, client: OpenAI, async_client: AsyncOpenAI
) -> None:
"""Transport errors while draining after [DONE] must not fail an already-complete stream."""

def body() -> Iterator[bytes]:
yield b'data: {"foo":true}\n\n'
yield b"data: [DONE]\n\n"
raise httpx.RemoteProtocolError("peer closed connection")

response = httpx.Response(200, content=body() if sync else to_aiter(body()))

if sync:
stream: Stream[object] | AsyncStream[object] = Stream(cast_to=object, client=client, response=response)
chunks = list(stream)
else:
stream = AsyncStream(cast_to=object, client=async_client, response=response)
chunks = [chunk async for chunk in stream]

assert chunks == [{"foo": True}]
assert response.is_closed is True


@pytest.mark.asyncio
@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
async def test_drain_decode_error_after_done_preserves_result(
sync: bool, client: OpenAI, async_client: AsyncOpenAI
) -> None:
"""Malformed trailing bytes after [DONE] must not fail an already-complete stream."""

def body() -> Iterator[bytes]:
yield b'data: {"foo":true}\n\n'
yield b"data: [DONE]\n\n"
# Truncated multi-byte UTF-8 sequence that the SSE decoder will reject.
yield b"data: \xff\n\n"

response = httpx.Response(200, content=body() if sync else to_aiter(body()))

if sync:
stream: Stream[object] | AsyncStream[object] = Stream(cast_to=object, client=client, response=response)
chunks = list(stream)
else:
stream = AsyncStream(cast_to=object, client=async_client, response=response)
chunks = [chunk async for chunk in stream]

assert chunks == [{"foo": True}]
assert response.is_closed is True


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