fix(streaming): drain remaining bytes after [DONE] before closing response#3534
fix(streaming): drain remaining bytes after [DONE] before closing response#3534mahesh-sadupalli wants to merge 1 commit into
Conversation
…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
There was a problem hiding this comment.
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 beforeclose()/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_callsis populated bytracking_aclose()but never asserted/used. Adding an assertion avoids an unused-variable lint (F841) and verifiesresponse.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.
| from typing import Iterator, AsyncIterator | ||
| from unittest.mock import patch, MagicMock, AsyncMock | ||
|
|
||
| import httpx |
| # 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: |
There was a problem hiding this comment.
💡 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".
| for _ in iterator: | ||
| pass |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
Fixes #3440.
Stream.__stream__andAsyncStream.__stream__break out of their SSE loop immediately on[DONE], then thefinallyblock callsresponse.close()/response.aclose(). If the HTTP/1.1 chunked terminator (0\r\n\r\n) has not yet been read, h11'stheir_stateis stillSEND_RESPONSEat 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_remote_disconnecthttpcore.RemoteProtocolErroron subsequent requestsThis is a regression from
6132922c("fix(client): close streams without requiring full consumption"), which removed the drain that7e2b2544had originally added.Fix
After seeing
[DONE], exhaust the SSE iterator beforebreak-ing. This causes the underlyingresponse.iter_bytes()/response.aiter_bytes()to be fully consumed, advancing h11's state machine toDONEbeforeclose()is called. httpcore then takes the graceful connection-to-pool branch.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) totests/test_streaming.py. The test injects a sentinel yield after[DONE]and asserts it was consumed beforeclose()was invoked.