Skip to content

Commit 14635de

Browse files
fix: close upstream LLM stream on client disconnect (port BerriAI#30245) (#184)
On client disconnect mid-stream, Starlette abandons the response body iterator without calling aclose(), leaving the upstream vLLM connection open and the GPU slot held until GC (zombie streams -> pool/slot starvation -> 540s timeouts under load). Wrap the streaming response so its __call__ finally force-closes both the body iterator and the upstream generator, triggering the existing response.aclose() cleanup; add BaseModelResponseIterator.aclose()/http_response so the close reaches the live connection. Both proxy streaming paths (/chat/completions and /v1/messages) are covered via create_response. Ports BerriAI#30245. Verified: 92 tests pass; ruff clean; black-clean on added lines. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 46518e4 commit 14635de

5 files changed

Lines changed: 303 additions & 4 deletions

File tree

litellm/llms/base_llm/base_model_iterator.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import json
22
from abc import abstractmethod
3-
from typing import List, Optional, Union, cast
3+
from typing import TYPE_CHECKING, List, Optional, Union, cast
44

55
import litellm
6+
7+
if TYPE_CHECKING:
8+
import httpx
69
from litellm.types.utils import (
710
Choices,
811
Delta,
@@ -64,6 +67,18 @@ def __init__(
6467
self.streaming_response = streaming_response
6568
self.response_iterator = self.streaming_response
6669
self.json_mode = json_mode
70+
self.http_response: Optional["httpx.Response"] = None
71+
72+
async def aclose(self) -> None:
73+
"""Close the upstream HTTP response so the provider connection is
74+
released (and a backend like vLLM aborts generation) when the stream
75+
is abandoned before its natural end.
76+
77+
``streaming_response`` is usually a bare ``aiter_lines()`` generator
78+
that holds no reference to the response, so the handler that owns the
79+
response attaches it here after construction."""
80+
if self.http_response is not None:
81+
await self.http_response.aclose()
6782

6883
def chunk_parser(
6984
self, chunk: dict

litellm/llms/custom_httpx/llm_http_handler.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
from litellm.llms.base_llm.audio_transcription.transformation import (
3232
BaseAudioTranscriptionConfig,
3333
)
34-
from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
34+
from litellm.llms.base_llm.base_model_iterator import (
35+
BaseModelResponseIterator,
36+
MockResponseIterator,
37+
)
3538
from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig
3639
from litellm.llms.base_llm.chat.transformation import BaseConfig
3740
from litellm.llms.base_llm.containers.transformation import BaseContainerConfig
@@ -783,6 +786,8 @@ async def make_async_call_stream_helper(
783786
completion_stream = provider_config.get_model_response_iterator(
784787
streaming_response=response.aiter_lines(), sync_stream=False
785788
)
789+
if isinstance(completion_stream, BaseModelResponseIterator):
790+
completion_stream.http_response = response
786791
# LOGGING
787792
logging_obj.post_call(
788793
input=messages,

litellm/proxy/common_request_processing.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
Union,
1616
)
1717

18+
import anyio
1819
import httpx
1920
import orjson
2021
from fastapi import HTTPException, Request, status
2122
from fastapi.responses import JSONResponse, Response, StreamingResponse
23+
from starlette.types import Receive, Scope, Send
2224

2325
import litellm
2426
from litellm._logging import verbose_proxy_logger
@@ -150,6 +152,64 @@ def _extract_error_from_sse_chunk(event_line: Union[str, bytes]) -> dict:
150152
return default_error
151153

152154

155+
async def _aclose_upstream_response(response: Any) -> None:
156+
"""Release the upstream HTTP connection when a stream ends for any
157+
reason, including client disconnect. Mirrors the finally block of
158+
async_data_generator in proxy_server.py."""
159+
with anyio.CancelScope(shield=True):
160+
if hasattr(response, "aclose"):
161+
try:
162+
await response.aclose()
163+
except BaseException as e:
164+
verbose_proxy_logger.debug(
165+
"error closing upstream response stream: %s", e
166+
)
167+
168+
169+
class _UpstreamClosingStreamingResponse(StreamingResponse):
170+
"""StreamingResponse that always closes its body iterator and the wrapped
171+
upstream generator.
172+
173+
When the client disconnects mid-stream, Starlette abandons the body
174+
iterator without calling aclose(), leaving the upstream LLM connection
175+
open until garbage collection; the backend (e.g. vLLM) keeps generating
176+
into a dead pipe. The upstream generator is closed directly (not via the
177+
body iterator) because aclose() on a never-started generator skips its
178+
body, so a cascade through it would be a no-op if the client disconnects
179+
before the first chunk is sent.
180+
"""
181+
182+
def __init__(
183+
self,
184+
content: AsyncGenerator[str, None],
185+
*,
186+
media_type: Optional[str] = None,
187+
headers: Optional[dict] = None,
188+
status_code: int = status.HTTP_200_OK,
189+
upstream_generator: Optional[AsyncGenerator[str, None]] = None,
190+
) -> None:
191+
super().__init__(
192+
content, status_code=status_code, headers=headers, media_type=media_type
193+
)
194+
self._upstream_generator = upstream_generator
195+
196+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
197+
try:
198+
await super().__call__(scope, receive, send)
199+
finally:
200+
with anyio.CancelScope(shield=True):
201+
for target in (self.body_iterator, self._upstream_generator):
202+
aclose = getattr(target, "aclose", None)
203+
if aclose is None:
204+
continue
205+
try:
206+
await aclose()
207+
except BaseException as e:
208+
verbose_proxy_logger.debug(
209+
"error closing streaming generator: %s", e
210+
)
211+
212+
153213
async def create_response(
154214
generator: AsyncGenerator[str, None],
155215
media_type: str,
@@ -246,11 +306,12 @@ async def combined_generator() -> AsyncGenerator[str, None]:
246306
with tracer.trace(DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE):
247307
yield chunk
248308

249-
return StreamingResponse(
309+
return _UpstreamClosingStreamingResponse(
250310
combined_generator(),
251311
media_type=media_type,
252312
headers=headers,
253313
status_code=final_status_code,
314+
upstream_generator=generator,
254315
)
255316

256317

@@ -1666,7 +1727,7 @@ def return_sse_chunk(chunk: Any) -> str:
16661727
return chunk
16671728

16681729
@staticmethod
1669-
async def async_streaming_data_generator(
1730+
async def async_streaming_data_generator( # noqa: PLR0915
16701731
response: Any,
16711732
user_api_key_dict: UserAPIKeyAuth,
16721733
request_data: dict,
@@ -1771,6 +1832,8 @@ async def async_streaming_data_generator(
17711832
code=getattr(e, "status_code", 500),
17721833
)
17731834
yield serialize_error(proxy_exception)
1835+
finally:
1836+
await _aclose_upstream_response(response)
17741837

17751838
@staticmethod
17761839
async def async_sse_data_generator(

tests/test_litellm/llms/base_llm/test_base_model_iterator.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,38 @@ async def async_gen():
223223

224224
assert len(chunks) == 1
225225
assert "response.created" in chunks[0]["text"]
226+
227+
228+
@pytest.mark.asyncio
229+
async def test_aclose_closes_attached_http_response():
230+
"""Regression for BerriAI/litellm#30244: CustomStreamWrapper.aclose() can
231+
only release the upstream provider connection if the iterator exposes
232+
aclose() and it reaches the underlying HTTP response. Without this, a
233+
client disconnect leaves backends like vLLM generating into a dead pipe."""
234+
from unittest.mock import AsyncMock, MagicMock
235+
236+
async def async_gen():
237+
yield "data: {}"
238+
239+
iterator = BaseModelResponseIterator(
240+
streaming_response=async_gen(), sync_stream=False
241+
)
242+
http_response = MagicMock()
243+
http_response.aclose = AsyncMock()
244+
iterator.http_response = http_response
245+
246+
await iterator.aclose()
247+
248+
http_response.aclose.assert_awaited_once()
249+
250+
251+
@pytest.mark.asyncio
252+
async def test_aclose_is_noop_without_http_response():
253+
async def async_gen():
254+
yield "data: {}"
255+
256+
iterator = BaseModelResponseIterator(
257+
streaming_response=async_gen(), sync_stream=False
258+
)
259+
260+
await iterator.aclose()

tests/test_litellm/proxy/test_common_request_processing.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import copy
23
import datetime
34
from typing import AsyncGenerator
@@ -19,6 +20,7 @@
1920
_is_azure_model_router_request,
2021
_override_openai_response_model,
2122
_parse_event_data_for_error,
23+
_UpstreamClosingStreamingResponse,
2224
create_response,
2325
)
2426
from litellm.proxy.dd_span_tagger import DDSpanTagger
@@ -1853,3 +1855,182 @@ def test_depth_limit_prevents_infinite_loop(self):
18531855
exc_a.__context__ = exc_b
18541856
exc_b.__context__ = exc_a # circular
18551857
assert _has_attribute_error_in_chain(exc_a) is False
1858+
1859+
1860+
class TestStreamCloseOnDisconnect:
1861+
"""
1862+
Coverage for closing the upstream LLM stream when the client disconnects
1863+
mid-stream. Starlette abandons the response body iterator without calling
1864+
aclose(), so without these hooks the proxy->backend connection stays open
1865+
and the backend (e.g. vLLM) keeps generating into a dead pipe.
1866+
"""
1867+
1868+
async def test_response_closes_body_iterator_when_task_cancelled(self):
1869+
"""Cancellation landing in send() leaves the generator suspended at a
1870+
yield; only the response-level finally can close it."""
1871+
closed = asyncio.Event()
1872+
1873+
async def body():
1874+
try:
1875+
while True:
1876+
yield "data: x\n\n"
1877+
finally:
1878+
closed.set()
1879+
1880+
response = _UpstreamClosingStreamingResponse(
1881+
body(), media_type="text/event-stream"
1882+
)
1883+
1884+
async def receive():
1885+
await asyncio.Event().wait()
1886+
1887+
async def send(message):
1888+
if message["type"] == "http.response.body":
1889+
await asyncio.Event().wait()
1890+
1891+
task = asyncio.create_task(response({"type": "http"}, receive, send))
1892+
await asyncio.sleep(0.05)
1893+
assert not closed.is_set()
1894+
1895+
task.cancel()
1896+
with pytest.raises(asyncio.CancelledError):
1897+
await task
1898+
1899+
assert closed.is_set()
1900+
1901+
async def test_response_closes_body_iterator_on_http_disconnect(self):
1902+
closed = asyncio.Event()
1903+
disconnected = asyncio.Event()
1904+
body_sends = 0
1905+
1906+
async def body():
1907+
try:
1908+
for i in range(1000):
1909+
yield f"data: {i}\n\n"
1910+
finally:
1911+
closed.set()
1912+
1913+
response = _UpstreamClosingStreamingResponse(
1914+
body(), media_type="text/event-stream"
1915+
)
1916+
1917+
async def receive():
1918+
await disconnected.wait()
1919+
return {"type": "http.disconnect"}
1920+
1921+
async def send(message):
1922+
nonlocal body_sends
1923+
if message["type"] == "http.response.body":
1924+
body_sends += 1
1925+
if body_sends == 3:
1926+
disconnected.set()
1927+
await asyncio.sleep(0.05)
1928+
1929+
await response({"type": "http"}, receive, send)
1930+
1931+
assert closed.is_set()
1932+
assert body_sends < 1000
1933+
1934+
async def test_upstream_closed_even_if_body_iterator_aclose_raises(self):
1935+
"""A BaseException from body_iterator.aclose() (e.g. CancelledError)
1936+
must not prevent the upstream generator from being closed."""
1937+
upstream_closed = asyncio.Event()
1938+
1939+
class ExplodingIterator:
1940+
def __aiter__(self):
1941+
return self
1942+
1943+
async def __anext__(self):
1944+
raise StopAsyncIteration
1945+
1946+
async def aclose(self):
1947+
raise asyncio.CancelledError()
1948+
1949+
async def upstream():
1950+
try:
1951+
yield "data: a\n\n"
1952+
finally:
1953+
upstream_closed.set()
1954+
1955+
upstream_gen = upstream()
1956+
await upstream_gen.__anext__()
1957+
response = _UpstreamClosingStreamingResponse(
1958+
ExplodingIterator(),
1959+
media_type="text/event-stream",
1960+
upstream_generator=upstream_gen,
1961+
)
1962+
1963+
async def receive():
1964+
await asyncio.Event().wait()
1965+
1966+
async def send(message):
1967+
pass
1968+
1969+
await response({"type": "http"}, receive, send)
1970+
1971+
assert upstream_closed.is_set()
1972+
1973+
async def test_create_response_closes_wrapped_generator_on_cancellation(self):
1974+
"""End to end through create_response: the upstream-facing generator
1975+
must be closed even when the body iterator was never started (client
1976+
gone before the first chunk could be sent)."""
1977+
inner_closed = asyncio.Event()
1978+
1979+
async def wrapped():
1980+
try:
1981+
while True:
1982+
yield "data: a\n\n"
1983+
finally:
1984+
inner_closed.set()
1985+
1986+
response = await create_response(
1987+
generator=wrapped(), media_type="text/event-stream", headers={}
1988+
)
1989+
1990+
async def receive():
1991+
await asyncio.Event().wait()
1992+
1993+
async def send(message):
1994+
await asyncio.Event().wait()
1995+
1996+
task = asyncio.create_task(response({"type": "http"}, receive, send))
1997+
await asyncio.sleep(0.05)
1998+
task.cancel()
1999+
with pytest.raises(asyncio.CancelledError):
2000+
await task
2001+
2002+
assert inner_closed.is_set()
2003+
2004+
async def test_async_streaming_data_generator_closes_upstream_on_early_close(
2005+
self,
2006+
):
2007+
class FakeUpstream:
2008+
def __init__(self):
2009+
self.aclosed = False
2010+
2011+
def __aiter__(self):
2012+
return self
2013+
2014+
async def __anext__(self):
2015+
return {"type": "chunk"}
2016+
2017+
async def aclose(self):
2018+
self.aclosed = True
2019+
2020+
upstream = FakeUpstream()
2021+
gen = ProxyBaseLLMRequestProcessing.async_streaming_data_generator(
2022+
response=upstream,
2023+
user_api_key_dict=MagicMock(spec=UserAPIKeyAuth),
2024+
request_data={"model": "mock-model"},
2025+
proxy_logging_obj=ProxyLogging(user_api_key_cache=MagicMock()),
2026+
serialize_chunk=lambda c: "data: x\n\n",
2027+
serialize_error=lambda e: "data: error\n\n",
2028+
)
2029+
2030+
await gen.__anext__()
2031+
await gen.__anext__()
2032+
assert not upstream.aclosed
2033+
2034+
await gen.aclose()
2035+
2036+
assert upstream.aclosed

0 commit comments

Comments
 (0)