test(responses): cover the polling disconnect regression through background_streaming_task

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
jesus 2026-09-07 12:07:31 +00:00
parent 9c8caae1e4
commit e589a0ed82
5 changed files with 59 additions and 54 deletions

View file

@ -155,7 +155,6 @@ jobs:
tests/test_litellm/proxy/vector_store_files_endpoints
tests/test_litellm/proxy/video_endpoints
tests/test_litellm/proxy/response_api_endpoints
tests/test_litellm/proxy/response_polling
tests/test_litellm/proxy/image_endpoints
tests/test_litellm/proxy/ocr_endpoints
tests/test_litellm/proxy/vector_store_endpoints

View file

@ -298,7 +298,7 @@ test-unit-proxy-core: install-test-deps
$(UV_RUN) pytest tests/test_litellm/proxy/auth tests/test_litellm/proxy/client tests/test_litellm/proxy/db tests/test_litellm/proxy/hooks tests/test_litellm/proxy/policy_engine --tb=short -vv -n 4 --durations=20
test-unit-proxy-misc: install-test-deps
$(UV_RUN) pytest tests/test_litellm/proxy/_experimental tests/test_litellm/proxy/agent_endpoints tests/test_litellm/proxy/anthropic_endpoints tests/test_litellm/proxy/common_utils tests/test_litellm/proxy/discovery_endpoints tests/test_litellm/proxy/experimental tests/test_litellm/proxy/google_endpoints tests/test_litellm/proxy/health_endpoints tests/test_litellm/proxy/image_endpoints tests/test_litellm/proxy/middleware tests/test_litellm/proxy/openai_files_endpoint tests/test_litellm/proxy/pass_through_endpoints tests/test_litellm/proxy/prompts tests/test_litellm/proxy/public_endpoints tests/test_litellm/proxy/response_api_endpoints tests/test_litellm/proxy/response_polling tests/test_litellm/proxy/shutdown tests/test_litellm/proxy/spend_tracking tests/test_litellm/proxy/ui_crud_endpoints tests/test_litellm/proxy/vector_store_endpoints tests/test_litellm/proxy/test_*.py --tb=short -vv -n 4 --durations=20
$(UV_RUN) pytest tests/test_litellm/proxy/_experimental tests/test_litellm/proxy/agent_endpoints tests/test_litellm/proxy/anthropic_endpoints tests/test_litellm/proxy/common_utils tests/test_litellm/proxy/discovery_endpoints tests/test_litellm/proxy/experimental tests/test_litellm/proxy/google_endpoints tests/test_litellm/proxy/health_endpoints tests/test_litellm/proxy/image_endpoints tests/test_litellm/proxy/middleware tests/test_litellm/proxy/openai_files_endpoint tests/test_litellm/proxy/pass_through_endpoints tests/test_litellm/proxy/prompts tests/test_litellm/proxy/public_endpoints tests/test_litellm/proxy/response_api_endpoints tests/test_litellm/proxy/shutdown tests/test_litellm/proxy/spend_tracking tests/test_litellm/proxy/ui_crud_endpoints tests/test_litellm/proxy/vector_store_endpoints tests/test_litellm/proxy/test_*.py --tb=short -vv -n 4 --durations=20
test-unit-integrations: install-test-deps
$(UV_RUN) pytest tests/test_litellm/integrations --tb=short -vv -n 4 --durations=20

View file

@ -14,6 +14,7 @@ These tests ensure the polling handler correctly manages response state
following the OpenAI Response API format.
"""
import asyncio
import json
from datetime import datetime, timezone
from typing import Any, Dict, Optional
@ -1663,6 +1664,63 @@ class TestBackgroundStreamingTerminalEvents:
final_call = handler.update_state.call_args_list[-1]
assert final_call.kwargs["status"] == "completed"
@pytest.mark.asyncio
async def test_polling_client_disconnect_does_not_cancel_upstream_call(self):
"""The polling client hangs up right after getting its polling id. The detached task
must still stream the upstream response through the client-disconnect guards."""
from litellm.proxy.common_request_processing import create_response
from litellm.proxy.response_polling.background_streaming import (
background_streaming_task,
)
async def client_already_left():
return {"type": "http.disconnect"}
async def slow_upstream_stream():
await asyncio.sleep(0.05)
for event in (
{"type": "response.in_progress"},
{
"type": "response.completed",
"response": {
"id": "resp_123",
"status": "completed",
"usage": {"input_tokens": 13, "output_tokens": 10},
"model": "gpt-4o",
"output": [{"id": "item_1", "type": "message"}],
},
},
):
yield f"data: {json.dumps(event)}\n\n"
async def upstream_call_behind_disconnect_guard(**kwargs):
return await create_response(
slow_upstream_stream(), "text/event-stream", {}, request=kwargs["request"]
)
handler = AsyncMock(spec=ResponsePollingHandler)
kwargs = _make_background_streaming_kwargs("poll_7", handler)
kwargs["request"] = Request(
{
"type": "http",
"method": "POST",
"path": "/v1/responses",
"headers": [(b"x-litellm-call-id", b"call-123")],
"query_string": b"",
},
client_already_left,
)
with patch(
"litellm.proxy.response_polling.background_streaming.ProxyBaseLLMRequestProcessing"
) as MockProcessor:
MockProcessor.return_value.base_process_llm_request = upstream_call_behind_disconnect_guard
await background_streaming_task(**kwargs)
final_call = handler.update_state.call_args_list[-1]
assert final_call.kwargs["status"] == "completed"
assert final_call.kwargs["usage"] == {"input_tokens": 13, "output_tokens": 10}
class TestEdgeCases:
"""Test edge cases and error scenarios"""

View file

@ -1,52 +0,0 @@
import asyncio
from collections.abc import AsyncGenerator
from typing import Final
import pytest
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.types import Message
from litellm.constants import LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED
from litellm.proxy.common_request_processing import create_response
from litellm.proxy.response_polling.background_streaming import detach_request_from_client
def _request_whose_client_already_left() -> Request:
async def receive() -> Message:
return {"type": "http.disconnect"}
scope: Final = {
"type": "http",
"method": "POST",
"path": "/v1/responses",
"headers": [(b"x-litellm-call-id", b"call-123")],
"query_string": b"",
}
return Request(scope, receive)
async def _slow_first_chunk() -> AsyncGenerator[str, None]:
await asyncio.sleep(0.05)
yield 'data: {"type": "response.created"}\n\n'
@pytest.mark.asyncio
async def test_detached_request_survives_client_disconnect_before_first_chunk():
original: Final = _request_whose_client_already_left()
cancelled: Final = await create_response(_slow_first_chunk(), "text/event-stream", {}, request=original)
assert isinstance(cancelled, JSONResponse)
assert cancelled.status_code == LITELLM_HTTP_STATUS_CLIENT_DISCONNECTED
detached: Final = detach_request_from_client(original)
kept_alive: Final = await create_response(_slow_first_chunk(), "text/event-stream", {}, request=detached)
assert isinstance(kept_alive, StreamingResponse)
assert kept_alive.status_code == 200
def test_detached_request_keeps_scope():
original: Final = _request_whose_client_already_left()
detached: Final = detach_request_from_client(original)
assert detached.headers["x-litellm-call-id"] == "call-123"
assert detached.scope is original.scope