Skip to content

fix(streaming): drain remaining bytes after [DONE] before closing response#3534

Open
mahesh-sadupalli wants to merge 1 commit into
openai:mainfrom
mahesh-sadupalli:fix/stream-drain-before-close
Open

fix(streaming): drain remaining bytes after [DONE] before closing response#3534
mahesh-sadupalli wants to merge 1 commit into
openai:mainfrom
mahesh-sadupalli:fix/stream-drain-before-close

Conversation

@mahesh-sadupalli

Copy link
Copy Markdown

Summary

Fixes #3440.

Stream.__stream__ and AsyncStream.__stream__ break out of their SSE loop immediately on [DONE], then the finally block calls response.close() / response.aclose(). If the HTTP/1.1 chunked terminator (0\r\n\r\n) has not yet been read, h11's their_state is still SEND_RESPONSE at the moment of close, so httpcore takes the destroy-connection branch instead of returning it to the pool. The result is a spurious TCP FIN, causing:

  • downstream proxies (envoy, nginx) to log downstream_remote_disconnect
  • occasional httpcore.RemoteProtocolError on subsequent requests

This is a regression from 6132922c ("fix(client): close streams without requiring full consumption"), which removed the drain that 7e2b2544 had originally added.

Fix

After seeing [DONE], exhaust the SSE iterator before break-ing. This causes the underlying response.iter_bytes() / response.aiter_bytes() to be fully consumed, advancing h11's state machine to DONE before close() is called. httpcore then takes the graceful connection-to-pool branch.

# sync
if sse.data.startswith("[DONE]"):
    for _ in iterator:   # drain chunked terminator
        pass
    break

# async
if sse.data.startswith("[DONE]"):
    async for _ in iterator:
        pass
    break

The drain runs only on the clean [DONE] path. Early consumer exit and error paths are unchanged.

Testing

Added test_done_drains_remaining_bytes_before_close (parametrized sync/async) to tests/test_streaming.py. The test injects a sentinel yield after [DONE] and asserts it was consumed before close() was invoked.

pytest tests/test_streaming.py -v
# 22 passed (all existing tests + 2 new)

…ponse

When Stream/__stream__ breaks on [DONE] it immediately runs the finally
block which calls response.close() / response.aclose(). If the HTTP/1.1
chunked terminator (0\r\n\r\n) has not yet been read by h11, their_state
is still SEND_RESPONSE at close() time, so httpcore takes the "destroy
connection" branch instead of returning it to the pool. This emits a
spurious TCP FIN and causes downstream proxies to log
downstream_remote_disconnect.

Regression introduced in 6132922 ("fix(client): close streams without
requiring full consumption") which removed the drain that 7e2b254 had
originally added.

Fix: after detecting [DONE], exhaust the SSE iterator before breaking so
that h11 finishes parsing the chunked terminator. The drain only runs on
the clean [DONE] path; early consumer exit / error paths are unchanged.

Adds a regression test (sync + async) that verifies trailing bytes are
consumed before response.close() is called.

Fixes openai#3440
@mahesh-sadupalli
mahesh-sadupalli requested a review from a team as a code owner July 23, 2026 14:26
Copilot AI review requested due to automatic review settings July 23, 2026 14:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a streaming regression where breaking immediately on the [DONE] SSE event can leave the underlying HTTP/1.1 response not fully consumed, causing httpcore to destroy (rather than pool) the connection and leading to spurious TCP FINs and occasional follow-on protocol errors.

Changes:

  • Drain the remaining SSE/byte iterator after observing [DONE] in both sync and async streaming paths before closing the response.
  • Add a regression test that ensures trailing bytes after [DONE] are consumed before close() / aclose() is invoked.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/openai/_streaming.py Drains the iterator after [DONE] so the response is fully consumed before closing, allowing connection reuse.
tests/test_streaming.py Adds a regression test for draining behavior (sync + async).
Comments suppressed due to low confidence (1)

tests/test_streaming.py:279

  • aclose_calls is populated by tracking_aclose() but never asserted/used. Adding an assertion avoids an unused-variable lint (F841) and verifies response.aclose() was invoked.
        assert drain_calls == ["trailing-bytes-yielded"], (
            "trailing bytes were not drained before aclose()"
        )



💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_streaming.py
Comment on lines 3 to 6
from typing import Iterator, AsyncIterator
from unittest.mock import patch, MagicMock, AsyncMock

import httpx
Comment thread tests/test_streaming.py
Comment on lines +256 to +260
# 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:

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50b8df0fd2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/openai/_streaming.py
Comment on lines +70 to +71
for _ in iterator:
pass

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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

**Streaming: connection force-closed (TCP FIN) after [DONE] SSE event because chunked terminator is not drained — regression from 6132922c**

2 participants