fix: drain remaining bytes after [DONE] before closing response (#3440) - #3520
fix: drain remaining bytes after [DONE] before closing response (#3440)#3520rkfshakti wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 84d0a11680
ℹ️ 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".
84d0a11 to
f5fc60e
Compare
|
Thanks for the review — all points addressed in P1 — StreamConsumed: You're right, calling P1 — Unused P2 — Async test response tracking: The async branch now creates P2 — Chunked terminator simulation: You're correct that @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f5fc60e344
ℹ️ 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".
| # Consume the full stream — the drain should consume the trailing event | ||
| for _ in stream: | ||
| pass | ||
| assert response.is_closed |
There was a problem hiding this comment.
Assert the trailing body is actually consumed
This assertion is true even if the new drain loop is removed, because Stream.__stream__/AsyncStream.__stream__ always closes the response in their finally after breaking on [DONE]; closing the httpx.Response can discard unread generator content. As a result, a regression that stops draining the trailing data: {"trailing":true} event still passes, despite the test claiming to verify full consumption. Track the body iterator with a sentinel/list and assert EOF was reached.
Useful? React with 👍 / 👎.
|
Thanks for the continued review — the P3 point about the test passing even without the drain loop is correct. Pushed P3 — Assert the trailing body is actually consumed: The body generator now appends a sentinel ( @codex review |
|
Security review completed. No security issues were found in this pull request. Reviewed commit: ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 30d491b813
ℹ️ 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".
| # `self.response.iter_bytes()`) because httpx only allows | ||
| # one active iterator at a time — a second call raises | ||
| # `httpx.StreamConsumed`. | ||
| for _ in iterator: |
There was a problem hiding this comment.
Do not propagate drain failures after [DONE]
When an upstream/proxy has already delivered the [DONE] sentinel but then stalls or closes before EOF/chunk termination, this new drain keeps reading and any ReadTimeout/RemoteProtocolError from the best-effort cleanup now escapes after the stream is logically complete. Before this change the stream ended at [DONE] and the finally block just closed the response, so users would not see a failure after receiving the complete stream; the async drain has the same issue. Consider making post-[DONE] draining best-effort so cleanup failures do not replace successful stream completion.
Useful? React with 👍 / 👎.
|
Hi maintainers — gentle bump on this fix for #3440. Drains remaining bytes after |
|
Hi maintainers — circling back on this fix for #3440. Drains remaining bytes after |
|
Hi maintainers — following up on this fix for #3440. Drains remaining bytes after the [DONE] sentinel before closing the streaming response to avoid connection reuse issues. CI is green. Would appreciate a review when time allows. Thanks! |
|
Friendly ping — this PR has been open for over 10 days. Would appreciate a human review when time allows. |
jbeckwith-oai
left a comment
There was a problem hiding this comment.
Reviewed the full sync and async streaming change at 30d491b. No blocking findings. Draining the existing SSE iterator reaches transport EOF without a second iter_bytes()/aiter_bytes() call, preserves early-close cleanup, and matches the established Node, Go, and historical Python stream behavior. The regression sentinel exercises both paths; all 22 streaming tests passed locally, along with Ruff checks and formatting validation.
|
Hi maintainers — this PR was approved by @jbeckwith-oai and is mergeable. It fixes #3440: |
30d491b to
d93a1ae
Compare
|
Hi maintainers — just a friendly nudge on this one. The fix for #3440 (draining remaining bytes after [DONE] before closing the response) has been approved by @jbeckwith-oai and is ready to merge. I'm excited to see it land — it prevents truncated streaming responses. Would appreciate a merge when you have a moment. Thanks! |
|
Hi @jbeckwith-oai — this PR was approved by you on Jul 21 and is still mergeable. The fix drains remaining bytes after [DONE] before closing the streaming response to prevent StreamConsumed errors (#3440). CI is green. Is there anything blocking the merge, or does it need a second approval? |
…ai#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 openai#3440
d93a1ae to
736f9f1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 736f9f1733
ℹ️ 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.
Drain raw bytes without decoding trailing SSE data
When a custom or misbehaving SSE endpoint sends [DONE] followed by a large unterminated body line, consuming iterator routes all trailing bytes through _SSELineDecoder, whose buffer is intentionally unbounded, and then copies and UTF-8-decodes that buffer at EOF. A logically completed stream can therefore consume memory proportional to the entire discarded body, potentially with several simultaneous copies; the async loop has the same behavior. Preserve the active raw byte iterator and discard its chunks after [DONE] without passing them through the SSE decoder.
Useful? React with 👍 / 👎.
Problem
When calling
client.chat.completions.create(..., stream=True)(orclient.responses.create(..., stream=True)),Stream.__stream__breaks out of its iteration loop as soon as the underlying SSE decoder yields adata: [DONE]event, and then immediately invokesresponse.close()inside thetry/finally.The problem is that the underlying
httpx.Response.iter_bytes()may not have been read to EOF at the moment of[DONE]. The HTTP/1.1 chunked transfer encoding terminator (0\r\n\r\n) may still be in flight. Whentheir_stateis not yeth11.DONE,response.close()takes the "destroy the connection" branch — emitting an immediate TCP FIN — instead of the graceful "back to pool (IDLE)" branch.This produces two classes of observable failure:
downstream_remote_disconnecthttpcore.RemoteProtocolError/httpx.RemoteProtocolError: peer closed connection without sending complete message bodyFix
Drain remaining bytes from
response.iter_bytes()(sync) /response.aiter_bytes()(async) after observing[DONE], so h11'stheir_stateadvances toDONEbeforeresponse.close()is called.With
their_state == h11.DONE, httpcore/h11 takes the graceful close path (back toIDLE, connection returns to the pool) rather than the destructive one.Test
Added
test_drain_after_done— verifies that after consuming a stream with[DONE]followed by trailing bytes (simulating the chunked terminator), the response is properly closed without premature termination.Fixes #3440