fix(proxy): close the upstream stream when a streaming body is never consumed - #2882
fix(proxy): close the upstream stream when a streaming body is never consumed#2882abhay-codes07 wants to merge 2 commits into
Conversation
…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
PR governanceThis PR does not yet satisfy the required template fields:
Please update the PR body, or move the PR back to draft while it is still in progress. |
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
JerrettDavis
left a comment
There was a problem hiding this comment.
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.
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.
|
Thanks, fixed in b525379. The minimal On the stronger-proof point: I added 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. |
Question: sequential idempotency vs. concurrent safety on mid-stream disconnectNice diagnosis — the before-body-iteration leak and its fix both check out. One gap I don't see covered: ScenarioNot the case this PR targets (client disconnects before
The actual question
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:
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. |
Description
A long-lived proxy serving Claude Code eventually goes unhealthy with:
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_innerthe upstream response is opened before the body generator, so its headers are available to build theStreamingResponse(ratelimit forwarding):The only thing that closes
upstream_responseon the success path is the generator's owncontextlib.aclosing: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
aclosingnever runs, and nothing else closes the stream. This is routine with Claude Code, which cancels or supersedes in-flight turns (ESC, a newer request,/compactside-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 surfacesMax 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_responsealready anticipates exactly this disconnect-mid-setup case for the session key:The opened upstream stream never got the same guarantee.
Fix
Attach an idempotent close as the
StreamingResponsebackgroundtask. 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, soaclose()is a no-op (httpx.Response.aclose()is idempotent).The already-correct paths are untouched: the
>= 400error path and the retry path close the stream explicitly, and the transport-error path returns before a stream is held.Type of Change
Changes Made
headroom/proxy/handlers/streaming.py(_stream_response_inner): attachbackground=BackgroundTask(_release_upstream_stream)to the successStreamingResponseso the upstream stream is closed even when the body generator is never iterated. Importsstarlette.background.BackgroundTask.tests/test_proxy_streaming_ratelimit_headers.py: addtest_upstream_stream_closed_when_body_never_consumed- builds the streaming response, does not consume the body (assertsaclosenot yet called), then runs the response's background task and asserts the upstreamacloseis awaited.Testing
pytest)ruff check)mypy)Test Output
Real Behavior Proof
send(stream=True)to theStreamingResponsereturn and confirmed the only close on the success path is the body generator'scontextlib.aclosing, which does not run if the body is never iterated. Reproduced that condition in the existing streaming unit harness (mock upstream whoseacloseis anAsyncMock), built the response, left the body unconsumed, and observedaclosewas never awaited before the fix; the response also carried nobackgroundtask. With the fix, the response carries a background task that awaits the upstreamaclose.Review Readiness
Checklist
CHANGELOG.md: it is generated by release-please from my Conventional Commit PR titleAdditional 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.