|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | | -from typing import Iterator, AsyncIterator |
| 3 | +from typing import Iterator, AsyncIterator, List |
4 | 4 |
|
5 | 5 | import httpx |
6 | 6 | import pytest |
@@ -216,6 +216,72 @@ def body() -> Iterator[bytes]: |
216 | 216 | assert sse.json() == {"content": "известни"} |
217 | 217 |
|
218 | 218 |
|
| 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 | + |
219 | 285 | async def to_aiter(iter: Iterator[bytes]) -> AsyncIterator[bytes]: |
220 | 286 | for chunk in iter: |
221 | 287 | yield chunk |
|
0 commit comments