Skip to content

Commit 10337ef

Browse files
committed
test: add regression tests for connection pool drain after [DONE]
Verify that Stream and AsyncStream drain remaining bytes before closing the response, ensuring h11 can return the connection to the pool. Regression guard for openai#3440.
1 parent 8dbcb64 commit 10337ef

1 file changed

Lines changed: 67 additions & 1 deletion

File tree

‎tests/test_streaming.py‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Iterator, AsyncIterator
3+
from typing import Iterator, AsyncIterator, List
44

55
import httpx
66
import pytest
@@ -216,6 +216,72 @@ def body() -> Iterator[bytes]:
216216
assert sse.json() == {"content": "известни"}
217217

218218

219+
def test_stream_drains_bytes_before_close_after_done(client: OpenAI) -> None:
220+
"""Stream drains remaining bytes after [DONE] so h11 can return the connection to the pool.
221+
222+
Regression guard for #3440: breaking on [DONE] without draining iter_bytes() leaves
223+
h11 in a non-DONE state, causing httpcore to destroy the connection (TCP FIN) instead
224+
of returning it to the pool.
225+
"""
226+
drained: List[bool] = []
227+
close_snapshots: List[bool] = []
228+
229+
def body() -> Iterator[bytes]:
230+
yield b"data: [DONE]\n\n"
231+
# Bytes that would arrive after [DONE] in real HTTP/1.1 chunked traffic
232+
# (the chunked terminator 0\r\n\r\n). The drain step must consume these.
233+
drained.append(True)
234+
yield b"0\r\n\r\n"
235+
236+
response = httpx.Response(200, content=body())
237+
orig_close = response.close
238+
239+
def tracked_close() -> None:
240+
# Snapshot whether drain happened at the moment close() is invoked
241+
close_snapshots.append(bool(drained))
242+
orig_close()
243+
244+
response.close = tracked_close # type: ignore[method-assign]
245+
246+
stream = Stream(cast_to=object, client=client, response=response)
247+
for _ in stream:
248+
pass # no events before [DONE]
249+
250+
assert drained, "trailing bytes were not consumed"
251+
# The FIRST close() call must happen after the drain
252+
assert close_snapshots, "response.close() was never called"
253+
assert close_snapshots[0], "response.close() was called before trailing bytes were drained"
254+
255+
256+
@pytest.mark.asyncio
257+
async def test_async_stream_drains_bytes_before_close_after_done(async_client: AsyncOpenAI) -> None:
258+
"""AsyncStream drains remaining bytes after [DONE] before closing. See #3440."""
259+
drained: List[bool] = []
260+
close_snapshots: List[bool] = []
261+
262+
async def async_body() -> AsyncIterator[bytes]:
263+
yield b"data: [DONE]\n\n"
264+
drained.append(True)
265+
yield b"0\r\n\r\n"
266+
267+
response = httpx.Response(200, content=async_body())
268+
orig_aclose = response.aclose
269+
270+
async def tracked_aclose() -> None:
271+
close_snapshots.append(bool(drained))
272+
await orig_aclose()
273+
274+
response.aclose = tracked_aclose # type: ignore[method-assign]
275+
276+
stream = AsyncStream(cast_to=object, client=async_client, response=response)
277+
async for _ in stream:
278+
pass
279+
280+
assert drained, "trailing bytes were not consumed"
281+
assert close_snapshots, "response.aclose() was never called"
282+
assert close_snapshots[0], "response.aclose() was called before trailing bytes were drained"
283+
284+
219285
async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]:
220286
for chunk in iter:
221287
yield chunk

0 commit comments

Comments
 (0)