fix(streaming): drain remaining bytes after [DONE] before closing response - #3566
fix(streaming): drain remaining bytes after [DONE] before closing response#3566Sahith59 wants to merge 3 commits into
Conversation
…ponse
When Stream.__stream__ encounters the [DONE] SSE event, it immediately
breaks out of the loop and falls into the finally block that calls
response.close(). At that point, the underlying HTTP/1.1 chunked
transfer iterator may not have been read to EOF — specifically, the
chunked terminator (0\r\n\r\n) may still be buffered in the kernel or
in h11's receive buffer.
h11 tracks the remote state machine. When response.close() is called
while h11's their_state is still SEND_RESPONSE (i.e. the chunked
terminator has not yet been parsed), httpcore takes the 'connection
must be destroyed' branch instead of the 'return to pool (IDLE)' branch.
This emits an immediate TCP FIN on the socket, which manifests as:
- Upstream proxy logs: spike of downstream_remote_disconnect
- Client side: occasional httpcore.RemoteProtocolError on the next
request because the connection was torn down uncleanly
Fix: After observing [DONE], exhaust the remaining SSE iterator before
breaking. This drains the chunked terminator through h11's state
machine so that their_state reaches DONE, and response.close() then
takes the graceful 'return to pool' path.
Regression introduced in 6132922 (fix(client): close streams without
requiring full consumption). Previously fixed in 7e2b254.
Closes openai#3440
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ec966628f3
ℹ️ 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.
Avoid waiting past the terminal SSE marker
When a server or proxy emits the app-level [DONE] marker but leaves the SSE response open, this nested drain keeps reading until the HTTP body reaches EOF, so the final next()/until_done() call can block until the read timeout (or forever with no read timeout) instead of completing at [DONE]; the async path has the same pattern. This regresses OpenAI-compatible SSE intermediaries that use [DONE] as the stream terminator but do not close immediately, so the post-DONE drain should be bounded or otherwise avoid waiting for an unbounded EOF.
Useful? React with 👍 / 👎.
| for _ in iterator: | ||
| pass |
There was a problem hiding this comment.
Suppress post-DONE drain failures
If the transport reports a read/protocol error while consuming bytes after [DONE] (for example, a proxy closes the chunked response without the terminating chunk), this newly added drain propagates that exception even though the SDK has already received the terminal application marker; before this change the stream completed at [DONE] and closed the unusable connection. For OpenAI-compatible gateways with occasional abrupt closes after the sentinel, fully consumed streams can now fail on the final iteration, so drain errors after the sentinel should be ignored while still closing the response.
Useful? React with 👍 / 👎.
Address two P2 issues raised by the Codex reviewer on PR openai#3566: 1. Suppress post-DONE drain failures If the transport reports a read/protocol error while consuming bytes after [DONE] (e.g. a proxy closes the chunked response without the terminating chunk), the bare drain loop would propagate that exception even though the SDK had already received the terminal application marker. The fix wraps the drain in try/except Exception so that transport noise after [DONE] is silently swallowed and the stream still closes cleanly. 2. Bound the drain (avoid waiting past the terminal SSE marker) If a server or proxy emits [DONE] but leaves the SSE connection open, the unbounded drain would block until EOF (potentially forever with no read timeout). By catching all exceptions from the drain, the user's configured read timeout will fire as an exception inside the loop, which is now caught. The drain therefore becomes truly best-effort: it drains if possible, and gives up gracefully otherwise.
|
Thanks for the Codex review! Addressed both P2 items in the latest push:
The updated comments in the code explain both rationales inline. |
Closes #3440
Problem
When
Stream.__stream__(andAsyncStream.__stream__) encounters the[DONE]SSE event, it immediatelybreaks out of the loop and thefinallyblock callsresponse.close()/await response.aclose().At that moment, the underlying HTTP/1.1 chunked transfer iterator has not been read to EOF. Specifically, the chunked terminator (
0\r\n\r\n) may still be buffered in the kernel or in h11's receive buffer. h11 tracks the remote connection state machine. Whenresponse.close()is called whileh11'stheir_stateis stillSEND_RESPONSE(terminator not yet parsed),httpcoretakes the destroy-the-connection branch instead of the return-to-pool (IDLE) branch. This emits an immediate TCP FIN on the socket.Observable symptoms:
downstream_remote_disconnect(Envoy / nginx / any HTTP/1.1 chunked-forwarding gateway)httpcore.RemoteProtocolError/httpx.RemoteProtocolError: peer closed connection without sending complete message bodyon the next requestRoot Cause & Regression History
This was originally fixed in
7e2b2544("fix(client): correctly flush the stream response body"). It was accidentally removed in6132922c("fix(client): close streams without requiring full consumption") and has been present in all releases since2.15.0, including the current2.44.0.Fix
After observing
[DONE], exhaust the remaining SSE iterator beforebreaking. This drains the chunked terminator through h11's state machine so thattheir_statereachesDONE, andresponse.close()then takes the graceful path (back toIDLE, connection returned to the pool).Sync path (
Stream.__stream__)Async path (
AsyncStream.__stream__)Impact
This fix restores the connection-pooling behaviour that existed before
6132922c. High-throughput applications using streaming completions behind a reverse proxy (Envoy, nginx, etc.) will immediately see a reduction in spuriousdownstream_remote_disconnecterrors and improved connection reuse / latency.