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 2 commits 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".
…umed (openai#3440) Codex P1 review: calling self.response.iter_bytes() a second time raises httpx.StreamConsumed because the SSE decoder already started consuming it. Fix: drain through the existing (which wraps response.iter_bytes()) instead of starting a new one. This consumes remaining SSE events until EOF, allowing h11 to advance to DONE state before close. Also fixes: - P2: async test now properly tracks the response object it passes into AsyncStream, so assert response.is_closed checks the right response - P2: test no longer tries to simulate chunked terminator bytes (which httpx.Response(content=...) doesn't expose); instead verifies the stream is fully consumed including trailing events after [DONE]
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 👍 / 👎.
The previous test asserted response.is_closed, which is true even without
the drain loop because Stream.__stream__/AsyncStream.__stream__ always
closes the response in their finally block after breaking on [DONE].
Closing the httpx.Response can discard unread generator content, so a
regression that stops draining the trailing event still passed.
Now the body generator appends a sentinel after yielding all events. The
test asserts the sentinel was reached, proving the drain loop actually
consumed the trailing data: {"trailing":true} event — not just that
the response was closed.
|
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 👍 / 👎.
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