Skip to content

fix(proxy): close the upstream stream when a streaming body is never consumed - #2882

Open
abhay-codes07 wants to merge 2 commits into
headroomlabs-ai:mainfrom
abhay-codes07:fix/streaming-upstream-stream-leak
Open

fix(proxy): close the upstream stream when a streaming body is never consumed#2882
abhay-codes07 wants to merge 2 commits into
headroomlabs-ai:mainfrom
abhay-codes07:fix/streaming-upstream-stream-leak

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Description

A long-lived proxy serving Claude Code eventually goes unhealthy with:

"upstream": {"status": "unhealthy", "error": "Max outbound streams is 100, 100 open"}

and clients start getting 502s until the service is restarted (#2797).

Root cause is a leaked upstream HTTP/2 stream on the streaming path. In _stream_response_inner the upstream response is opened before the body generator, so its headers are available to build the StreamingResponse (ratelimit forwarding):

upstream_response = await self.http_client.send(_upstream_req, stream=True)
...
return StreamingResponse(generate(), media_type="text/event-stream", headers=forwarded_headers)

The only thing that closes upstream_response on the success path is the generator's own contextlib.aclosing:

async def generate():
    ...
    async with contextlib.aclosing(upstream_response) as response:
        async for chunk in response.aiter_bytes():
            ...

That cleanup runs only if the body is iterated. When a client disconnects before Starlette starts sending the body, the generator is never started, its aclosing never runs, and nothing else closes the stream. This is routine with Claude Code, which cancels or supersedes in-flight turns (ESC, a newer request, /compact side-requests). Each abandoned request leaks one open h2 stream.

Leaked streams accumulate on the pooled upstream connection until it reaches the server's SETTINGS_MAX_CONCURRENT_STREAMS (100 for Anthropic). After that httpcore cannot open a new stream on it, the upstream health probe surfaces Max outbound streams is 100, 100 open, and the proxy stays unhealthy until restart. It builds up slowly, which matches the "after prolonged use" reports.

The outer _stream_response already anticipates exactly this disconnect-mid-setup case for the session key:

# any exception here - including asyncio.CancelledError from a client disconnect
# mid-setup - must still release session_key ...
except (Exception, asyncio.CancelledError):
    self._cleanup_mid_turn_stream(session_key)
    raise

The opened upstream stream never got the same guarantee.

Fix

Attach an idempotent close as the StreamingResponse background task. Starlette runs a response's background task after the body finishes and after an early client disconnect, so the stream is released in both cases. On the normal path the generator has already closed it, so aclose() is a no-op (httpx.Response.aclose() is idempotent).

async def _release_upstream_stream() -> None:
    with contextlib.suppress(Exception):
        await upstream_response.aclose()

return StreamingResponse(
    generate(),
    media_type="text/event-stream",
    headers=forwarded_headers,
    background=BackgroundTask(_release_upstream_stream),
)

The already-correct paths are untouched: the >= 400 error path and the retry path close the stream explicitly, and the transport-error path returns before a stream is held.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Changes Made

  • headroom/proxy/handlers/streaming.py (_stream_response_inner): attach background=BackgroundTask(_release_upstream_stream) to the success StreamingResponse so the upstream stream is closed even when the body generator is never iterated. Imports starlette.background.BackgroundTask.
  • tests/test_proxy_streaming_ratelimit_headers.py: add test_upstream_stream_closed_when_body_never_consumed - builds the streaming response, does not consume the body (asserts aclose not yet called), then runs the response's background task and asserts the upstream aclose is awaited.

Testing

  • Unit tests pass (pytest)
  • Linting passes (ruff check)
  • Type checking passes (mypy)
  • New test added

Test Output

# Fail-before (background= line removed, new test kept):
tests/test_proxy_streaming_ratelimit_headers.py::...::test_upstream_stream_closed_when_body_never_consumed FAILED
  assert result.background is not None -> AssertionError: streaming response must carry a cleanup task

# Pass-after (fix in place):
tests/test_proxy_streaming_ratelimit_headers.py  12 passed
tests/test_proxy_streaming_ratelimit_headers.py tests/test_h2_stream_reset_retry.py
tests/test_proxy_streaming_request_logger.py
tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py   34 passed

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/proxy/handlers/streaming.py -> Success: no issues found in 1 source file

Real Behavior Proof

  • Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.17 and mypy 1.20.2 via uvx.
  • Steps: traced the streaming forwarder from send(stream=True) to the StreamingResponse return and confirmed the only close on the success path is the body generator's contextlib.aclosing, which does not run if the body is never iterated. Reproduced that condition in the existing streaming unit harness (mock upstream whose aclose is an AsyncMock), built the response, left the body unconsumed, and observed aclose was never awaited before the fix; the response also carried no background task. With the fix, the response carries a background task that awaits the upstream aclose.
  • Observed result: an abandoned streaming request now releases its upstream stream instead of leaking it, so streams no longer accumulate toward the 100-stream ceiling on the pooled connection.
  • Not tested: an end-to-end multi-hour Claude Code session reproducing the 100-open exhaustion against a live Anthropic connection (no live long-running proxy here). The leak condition and its release are verified directly at the streaming handler with the same plumbing the proxy uses.

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective
  • New and existing unit tests pass locally with my changes
  • I did not edit CHANGELOG.md: it is generated by release-please from my Conventional Commit PR title

Additional Notes

aclose() is idempotent, so the background close is safe alongside the generator's own cleanup on the normal path - it simply becomes the sole closer when the generator never runs. The background task runs only after the response lifecycle completes, so it never races an in-flight stream.

…consumed

The streaming forwarder opens the upstream response with send(stream=True)
before building the StreamingResponse, but the only thing that closes it is the
body generator's own contextlib.aclosing. That runs only if the body is
iterated. When a client disconnects before Starlette starts sending the body
(routine when a harness like Claude Code cancels or supersedes an in-flight
turn), the generator never runs and nothing closes the upstream response, so its
HTTP/2 stream leaks.

Leaked streams accumulate on the pooled upstream connection until it reaches the
server's SETTINGS_MAX_CONCURRENT_STREAMS (100 for Anthropic). After that no new
stream can open, the health probe reports "Max outbound streams is 100, 100
open", and the proxy goes unhealthy until it is restarted (headroomlabs-ai#2797).

The outer _stream_response already releases the session key on this same
disconnect-mid-setup path; give the opened stream the same guarantee. Attach an
idempotent close as the StreamingResponse background task, which Starlette runs
after the body finishes and after an early client disconnect. On the normal path
the generator has already closed the stream, so aclose() is a no-op there.

Fixes headroomlabs-ai#2797
Copilot AI lite review requested due to automatic review settings August 9, 2026 23:04

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

PR governance

This PR does not yet satisfy the required template fields:

  • Fill in Real Behavior ProofExact command / steps.

Please update the PR body, or move the PR back to draft while it is still in progress.

@github-actions github-actions Bot added the status: needs author action Pull request body or readiness checklist still needs author updates label Aug 9, 2026
@codecov-commenter

codecov-commenter commented Aug 9, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The cleanup mechanism is directionally sound, and the new focused test plus the existing streaming suites pass locally. One repository integration is broken on the current head, however: tests/test_proxy_copilot_auth_hooks.py::test_streaming_response_applies_copilot_auth replaces FastAPI’s response classes with a minimal Response test double whose constructor does not accept background. _stream_response_inner() now unconditionally passes that keyword, so the real Copilot-auth streaming regression fails with TypeError both locally and in CI shard 3.

Please update that response double to accept/store the background task and extend the assertion so the existing auth hook and cleanup coexist. Ideally exercise the response lifecycle (including a simulated early disconnect) rather than only calling result.background() directly; that would prove Starlette invokes the close in the condition this PR targets, not just that a callable was attached. CI shard 4 is also red on the native-tls release-tree guard; that appears unrelated to these two changed files, but the head still needs a clean required run before approval.

@github-actions github-actions Bot added the status: ci failing Required or reported CI checks are failing label Aug 10, 2026
Addresses review feedback on headroomlabs-ai#2882. _stream_response_inner now attaches a
background task to release the upstream stream, but the minimal Response test
double in test_proxy_copilot_auth_hooks.py did not accept a background keyword,
so test_streaming_response_applies_copilot_auth failed with TypeError once the
forwarder passed it.

- Response double accepts and stores background; the streaming auth test now
  also asserts the cleanup task and the Copilot auth hook coexist on the
  response.
- Added a lifecycle regression to test_proxy_streaming_ratelimit_headers that
  drives the real Starlette response through an immediate client disconnect and
  asserts the upstream stream is released by the end of the call, proving the
  cleanup is actually invoked and not merely attached.
@abhay-codes07

Copy link
Copy Markdown
Contributor Author

Thanks, fixed in b525379.

The minimal Response test double in test_proxy_copilot_auth_hooks.py now accepts and stores the background keyword, so test_streaming_response_applies_copilot_auth no longer TypeErrors when the forwarder passes the cleanup task. That test also now asserts the Copilot auth hook and the cleanup task coexist on the response (response.background is not None).

On the stronger-proof point: I added test_upstream_stream_released_over_asgi_lifecycle_on_disconnect to test_proxy_streaming_ratelimit_headers.py. Instead of calling result.background() directly, it drives the real Starlette StreamingResponse.__call__ with a receive channel that returns http.disconnect immediately (client gone before the body is consumed) and asserts the upstream aclose() was awaited by the end of the lifecycle -- proving Starlette actually invokes the release in the condition this PR targets. The existing direct-invocation test is kept as the focused unit check. Full streaming suite + the copilot-auth streaming test pass; ruff clean.

Re the CI shard 4 native-tls release-tree guard: agreed it looks unrelated to these two files; I will keep an eye on the required re-run.

@github-actions github-actions Bot removed the status: ci failing Required or reported CI checks are failing label Aug 10, 2026
@ashishpatel26

Copy link
Copy Markdown
Contributor

Question: sequential idempotency vs. concurrent safety on mid-stream disconnect

Nice diagnosis — the before-body-iteration leak and its fix both check out. One gap I don't see covered:

Scenario

Not the case this PR targets (client disconnects before generate() starts iterating), but a mid-stream disconnect — the client goes away while generate() is actively inside async for chunk in response.aiter_bytes(). Two cleanup paths become live at once:

  1. Starlette unwinds the generator on disconnect → contextlib.aclosing.__aexit__upstream_response.aclose()
  2. Starlette also runs the response's background task after the send loop ends → _release_upstream_stream()upstream_response.aclose()

The actual question

aclose() being idempotent (if self.is_closed: return) protects against a second call after the first has already completed and set its flag — i.e., sequential double-close. It does not by itself protect against two coroutines both reading is_closed=False before either has set it — a TOCTOU race, not a sequencing guarantee.

So: does Starlette strictly sequence "generator teardown fully awaited" before "background task starts," or can the two run concurrently on a mid-stream disconnect? If it's the latter:

  • Does httpx.Response.aclose() (or the underlying h2/httpcore stream-close path) hold an internal lock that makes concurrent entry safe, or are these two independent close operations racing on the same stream object?
  • Is there a test that drives disconnect mid-iteration (not before-body-consumption) to prove both cleanup paths can't land concurrently? test_upstream_stream_released_over_asgi_lifecycle_on_disconnect proves the target bug (disconnect-before-iteration) is fixed, but doesn't appear to exercise the case where both paths are simultaneously live.

If Starlette's scheduling here is concurrent rather than sequential, that's a second, subtler failure mode — a double-close race, not a leak — that "idempotent" alone doesn't rule out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: needs author action Pull request body or readiness checklist still needs author updates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants