Skip to content

fix(proxy): close common streaming responses on exit - #28536

Open
WZStephen wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
nhcf:fix/common-streaming-close-on-disconnect
Open

fix(proxy): close common streaming responses on exit#28536
WZStephen wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
nhcf:fix/common-streaming-close-on-disconnect

Conversation

@WZStephen

@WZStephen WZStephen commented May 22, 2026

Copy link
Copy Markdown

Relevant issues

Related to #25776.

Linear ticket

N/A

Pre-Submission checklist

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

CI (LiteLLM team)

  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Screenshots / Proof of Fix

Targeted tests pass locally:

.venv/bin/python -m pytest tests/test_litellm/test_common_request_processing_streaming_cleanup.py
# 5 passed

Lint on changed files passes locally:

uv run --no-sync ruff check litellm/proxy/common_request_processing.py tests/test_litellm/test_common_request_processing_streaming_cleanup.py
# All checks passed

Type

🐛 Bug Fix
✅ Test

Changes

  • Close response.aclose() from ProxyBaseLLMRequestProcessing.async_streaming_data_generator in a shielded finally block.
  • Prevent leaked upstream streaming responses when the downstream generator is closed early, such as on client disconnect.
  • Add regression coverage for early exit, normal completion, mid-stream error, close-error, and task-cancellation paths.
  • Bound shielded cleanup with a 5-second deadline so hung close operations cannot hold the task forever.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a shielded finally block to ProxyBaseLLMRequestProcessing.async_streaming_data_generator that calls response.aclose() when the generator exits — whether normally, on error, or due to early client disconnect — preventing leaked upstream streaming connections.

  • common_request_processing.py: Imports anyio and appends a finally clause that wraps response.aclose() in anyio.CancelScope(shield=True), guarding cleanup from task cancellation; errors from aclose() are caught and logged at DEBUG level.
  • test_common_request_processing_streaming_cleanup.py: Four new mock-only tests verify that aclose() is called on the response object in each exit scenario (early exit, normal completion, mid-stream error, and a failing aclose() call).

Confidence Score: 4/5

The change is a targeted cleanup fix that adds a finally block to an existing generator; the core logic is straightforward and the new code path is isolated.

The cleanup itself is correct and the tests cover the main exit scenarios. Two gaps worth noting: the shielded cancel scope has no timeout, so a hung aclose() call could hold a task open indefinitely after client disconnect; and the tests exercise GeneratorExit (via generator.aclose()) rather than actual task cancellation, so the shield's effectiveness is not directly validated.

litellm/proxy/common_request_processing.py — specifically the absence of a deadline on the CancelScope; tests/test_litellm/test_common_request_processing_streaming_cleanup.py for the missing task-cancellation test case.

Important Files Changed

Filename Overview
litellm/proxy/common_request_processing.py Adds a shielded finally block to async_streaming_data_generator that calls response.aclose(), preventing upstream stream leaks on early client disconnect; anyio added as import. No timeout bounds the shielded cleanup.
tests/test_litellm/test_common_request_processing_streaming_cleanup.py New test file with 4 mock-only tests covering early exit, normal completion, mid-stream error, and swallowed close-error; does not exercise the task-cancellation path the shield is designed to protect.

Reviews (1): Last reviewed commit: "test(proxy): cover streaming response cl..." | Re-trigger Greptile

Comment on lines +1972 to +1982
with anyio.CancelScope(shield=True):
if hasattr(response, "aclose"):
try:
# This generator owns final cleanup of the raw upstream stream;
# iterator hooks should transform/drain it without closing it.
await response.aclose()
except BaseException as e:
verbose_proxy_logger.debug(
"async_streaming_data_generator: error closing response stream: %s",
e,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing timeout on shielded aclose()

anyio.CancelScope(shield=True) prevents the cleanup from being cancelled, but there's no deadline/timeout. If response.aclose() blocks (e.g., a misbehaving proxy or hung upstream TCP connection), this shielded scope will hold the task open indefinitely after the client has already disconnected. Adding a deadline to the scope — e.g., anyio.CancelScope(shield=True, deadline=anyio.current_time() + 5) — would bound the worst-case hang to a fixed window and still leave enough time for normal close operations to complete.

Comment on lines +19 to +47
@pytest.mark.asyncio
async def test_async_streaming_data_generator_closes_response_on_early_exit():
mock_response = MagicMock()
mock_response.aclose = AsyncMock()
mock_proxy_logging_obj = _mock_proxy_logging_obj()

async def mock_streaming_iterator(*args, **kwargs):
yield {"content": "hello"}
yield {"content": " world"}

mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = (
mock_streaming_iterator
)

generator = ProxyBaseLLMRequestProcessing.async_streaming_data_generator(
response=mock_response,
user_api_key_dict=MagicMock(spec=UserAPIKeyAuth),
request_data={"model": "gpt-3.5-turbo"},
proxy_logging_obj=mock_proxy_logging_obj,
serialize_chunk=lambda chunk: str(chunk),
serialize_error=lambda proxy_exception: str(proxy_exception.to_dict()),
)

first_chunk = await generator.__anext__()
assert first_chunk == "{'content': 'hello'}"

await generator.aclose()

mock_response.aclose.assert_awaited_once()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Early-exit test exercises GeneratorExit, not task cancellation

The shield in the implementation exists specifically to protect cleanup from asyncio task cancellation (i.e., the client-disconnect path). The test exercises generator.aclose(), which raises GeneratorExit inside the generator — a different code path than an asyncio.CancelledError delivered to the running task. Because GeneratorExit reaches the finally block regardless of shielding, the test passes even if the CancelScope(shield=True) were removed. A complementary test that cancels the task mid-stream (via asyncio.create_task + task.cancel()) would give confidence that the shield actually works under the condition it's guarding against.

@oss-pr-review-agent-shin

Copy link
Copy Markdown
Contributor

🤖 litellm-agent: This PR is currently BLOCKED from merge.

Score: 4/5

Why blocked:

  • 1 unresolved reviewer concern (greptile) (unresolved_concern, -1 pts)

Details: Score docked for: 1 unresolved reviewer concern (greptile).

Fix the issues above and push an update — the bot will re-review automatically.

Note: This bot is still in beta and might not always work as expected. Please share any feedback via Slack.

@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.

@github-actions github-actions Bot added the stale label Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants