Repository navigation
UN-4237 [FIX] Close streamed LLM responses so executor workers no longer deadlock on httpcore's pool lock - #2318
Conversation
…ot deadlock executor workers on httpcore's pool lock LiteLLM's sync CustomStreamWrapper has no close() and stops iterating before the HTTP body ends, so every streamed completion left its response checked out of httpcore's pool until the garbage collector finalised it. When that happened on a thread inside ConnectionPool.handle_request, the finaliser's PoolByteStream.close() waited on the same non-reentrant lock the thread held, and the worker hung forever with its lease still renewing. Add close_stream(), which closes the stream and the provider response behind it (litellm's ModelResponseIterator, the OpenAI SDK Stream, plain generators), and call it in a finally in collect_with_retry and iter_with_retry: on success, on error and on early exit. The retry backoff now runs after the stream is closed. Tests run the real LLM.complete() path against a local Anthropic SSE server with GC disabled, and force a collection inside the pool lock to reproduce the deadlock; they fail against the previous code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb
johnyrahul
left a comment
There was a problem hiding this comment.
Standardized review (unstract:review-deep, INITIAL) — reviewed at 7632d7ad9, base 02b979753
Verdict: APPROVE WITH NITS
Summary: Critical 0 · High 0 · Medium 4 · Low 3 · Lenses run: 19/19 (4 N/A)
The fix works for Anthropic, plain OpenAI/Azure chat, Bedrock and Vertex. All 13 new or changed tests fail against main's retry_utils.py and pass at HEAD, run against the pinned litellm 1.104.0. The pinned ruff 0.3.4 check and format --check both pass. Finding #1 is worth fixing before merge: one OpenAI path is still not closed. Details are inline.
| # | Severity | Finding |
|---|---|---|
| 1 | Medium | close_stream doesn't reach the HTTP response on litellm's Responses-API path (gpt-5-pro, *-codex, o3-pro, responses/<model>) |
| 2 | Medium | "When the caller abandons this generator" is not covered when cyclic GC finalises the generator; there is a real stream_complete() consumer in unstract-cloud's worker plugin |
| 3 | Medium | Closing the wrapper's own completion_stream (the whole fix for Bedrock) has no test |
| 4 | Medium | A failed close, which recreates the leak, is logged only at DEBUG |
| 5 | Low | "A drained stream is still open" holds for some providers, not all |
| 6 | Low | response is attributed to Vertex only; Gemini sets it too |
| 7 | Low | The test leaves a stub magic module in sys.modules |
Lens checklist
- Spec & intent — See findings #1, #2. The repo states no PR-title convention, so no title check was made.
- Architectural fit — Clean
- Correctness — See findings #1, #4
- Security — Clean. No endpoint, parser, auth or untrusted-input surface. Boundary sweep: 1 sink (the debug log line) traced, clean.
- Data integrity — N/A: no persisted data or migrations
- Concurrency — See finding #2
- API compatibility — Clean. No wire change. The retry helpers' only consumer is sdk1's
llm.py; I also searched unstract-cloud, llmwhisperer-portal, the verticals repos and omnibench-platform. - Reliability — See finding #1
- Performance & cost — Clean
- Observability — See finding #4
- Operational safety — Clean
- LLM/agent — Clean.
stream_chunk_builderreads only the collected chunks. - Testing — See findings #3, #7
- Dependencies — N/A: no dependency changes
- Code quality — Clean.
ruff0.3.4checkandformat --checkpass. - Doc & comment accuracy — See findings #2, #5, #6
- Cross-document consistency — Clean. The branch is current with
main, and the removed "leak sockets until GC" comments are not restated elsewhere. - On-prem — N/A: no chart or on-prem paths
- Scaling & lifecycle — N/A: no worker, pool or scaling config
Open questions for the author
- Has the UN-4136 600-user load test been re-run on a snapshot build? The PR lists it as still to do.
- Does any Unstract adapter offer Responses-API-routed models (
gpt-5-pro,*-codex,o3-pro)? The answer decides how much #1 matters in practice.
Assumptions
- The
table_extractorplugin in unstract-cloud runs in executor workers. - Verification used litellm 1.104.0, the pinned version. On 1.90.3, which some local venvs carry, the deadlock test also passes against
main.
Addresses the standardized review on #2318. - close_stream now walks the stream's handle attributes to a bounded depth with a visited set, instead of one fixed level. On litellm's Responses-API bridge (responses/<model> and the models routed there) the HTTP response sits under an iterator with no close(), one level below where the old walk stopped, so an error after content or an early close left the pool slot checked out. - A close that raises is logged at WARNING with the traceback: httpcore marks its stream closed before closing the socket, so that failure can hold the slot for good. - Comments and docstrings no longer claim an abandoned generator is covered: only a caller that closes it is. stream_complete's docstring says callers that stop early must close it. - Tests: socket tests for the Responses-API bridge (error after content, early close, drain), and unit tests for a generator completion_stream (Bedrock), an OpenAI-SDK-shaped Stream, the Responses chain, cycles, and the warning. Each was checked against a mutation of the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb
Addresses the standardized review on #2318. - close_stream now walks the stream's handle attributes to a bounded depth with a visited set, instead of one fixed level. On litellm's Responses-API bridge (responses/<model> and the models routed there) the HTTP response sits under an iterator with no close(), one level below where the old walk stopped, so an error after content or an early close left the pool slot checked out. - A close that raises is logged at WARNING with the traceback: httpcore marks its stream closed before closing the socket, so that failure can hold the slot for good. - Comments and docstrings no longer claim an abandoned generator is covered: only a caller that closes it is. stream_complete's docstring says callers that stop early must close it. - Tests: socket tests for the Responses-API bridge (error after content, early close, drain), and unit tests for a generator completion_stream (Bedrock), an OpenAI-SDK-shaped Stream, the Responses chain, cycles, and the warning. Each was checked against a mutation of the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb
|
Addresses Greptile's review on #2318: the close attribute was read outside the try, so a handle whose close property raised would stop the cleanup before the remaining handles and could replace the original stream error in the caller's finally. The lookup now sits inside the guarded block, with a test that fails without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb
Addresses Greptile's review on #2318: the close attribute was read outside the try, so a handle whose close property raised would stop the cleanup before the remaining handles and could replace the original stream error in the caller's finally. The lookup now sits inside the guarded block, with a test that fails without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb
chandrasekharan-zipstack
left a comment
There was a problem hiding this comment.
Review notes. Overall the fix looks correct: the walk can't reach a shared httpx client or pool, the original error is never masked, and the tests catch the regression.
Two inline comments are on the close-failure logging in close_stream. The rest are comment cleanup: ticket numbers, litellm version pins and "before the fix" wording that will go stale. The goal is to keep the explanation in one place (close_stream) and keep the other comments generic.
…stic comments From Chandru's review on #2318: - A failed close logs only its own traceback: close_stream runs in a finally while the provider error is propagating, so Python chained that error onto the close failure and exc_info printed it a second time. - close_stream takes the caller's logger and adapter description, and both retry helpers pass theirs, so a leaked pool slot can be traced to the provider that leaked it. - Comments: the explanation lives once, in close_stream's docstring (the only ticket reference left); the rest point at it. Dropped litellm version pins and "before the fix" wording, and described the Responses-API chain the same way everywhere (wrapper -> completion_stream -> streaming_response -> response). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb
…stic comments From Chandru's review on #2318: - A failed close logs only its own traceback: close_stream runs in a finally while the provider error is propagating, so Python chained that error onto the close failure and exc_info printed it a second time. - close_stream takes the caller's logger and adapter description, and both retry helpers pass theirs, so a leaked pool slot can be traced to the provider that leaked it. - Comments: the explanation lives once, in close_stream's docstring (the only ticket reference left); the rest point at it. Dropped litellm version pins and "before the fix" wording, and described the Responses-API chain the same way everywhere (wrapper -> completion_stream -> streaming_response -> response). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb
951a3f4 to
b1301c4
Compare
|
Unstract test resultsPer-group results
Critical paths
|



What
New
close_stream()helper inunstract/sdk1/utils/retry_utils.pythat closes a streamed completion and the HTTP response behind it. It walks the stream's handle attributes (completion_stream,streaming_response,response,http_response,stream_iterator) breadth-first, to a bounded depth with a visited set, and closes everything it reaches that has aclose(). That covers:ModelResponseIterator(Anthropic, Vertex/Gemini, the generic HTTP handler)Stream(OpenAI, Azure, OpenAI-compatible)responses/<model>, plus models litellm routes there such asgpt-5-pro,*-codexando3-pro), where the response sits at wrapper ->completion_stream->streaming_response->responseIt is duck-typed (no litellm import) and never raises. A failed close is logged at WARNING on the caller's logger, with the adapter info and only the close failure's own traceback (not the provider error that is propagating at the time).
collect_with_retry(used byLLM.complete()when streaming) anditer_with_retry(used byLLM.stream_complete()) now call it in afinally: on success, on error, and when the caller closes the generator early. They pass their logger and adapter description through.The retry backoff now runs after the failed attempt's stream is closed, so the connection isn't held through the sleep.
LLM.stream_complete()anditer_with_retrydocument that a caller who stops early must close the generator, e.g. withcontextlib.closing(...).Why
CustomStreamWrapperhas noclose(). On some providers (Anthropic among them) it also stops iterating before the HTTP body ends. So a streamed completion that failed, was abandoned, or on Anthropic simply finished left its response checked out of the pool until the garbage collector finalised it. When that happened on a thread insideConnectionPool.handle_request, the finaliser'sPoolByteStream.close()waited on the same non-reentrantthreading.Lockthe thread already held. The worker hung forever, with its lease still renewing.getattr(gen, "close", None)on the error path was a no-op for these streams.How
close_stream()releases the pool slot on the calling thread, outside any pool lock. Closing the line generator propagatesGeneratorExitdown toPoolByteStream.close(); closing anhttpx.Responsecloses its stream directly.finallyaround each attempt. Thetime.sleepmoved after thetryso it runs only once the stream is closed.Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)
stream_chunk_builderonly reads the collected chunks, so responses are unchanged.test_anthropic_stream_complete.pystill passes.Stream, a drained Responses stream), the extra close is a no-op.stream_complete()generator the caller abandons without closing is still finalised by the GC, possibly inside the pool lock. This is now documented onstream_complete. The one known consumer that can abandon it, unstract-cloud'stable_extractorplugin, needscontextlib.closing()around its loops; that is a separate change in that repo.Database Migrations
Env Config
Relevant Docs
Related Issues or PRs
Dependencies Versions
Notes on Testing
tests/test_stream_connection_release.pyruns real litellm against a local server speaking the provider's SSE format, with GC disabled, and checkspool._requestsis empty after:LLM.complete(): a completed stream, and a stream failing after content (overloaded_errorevent)openai/responses/gpt-4o): error after content, early close, and draingc.collect()inside the pool lock on the next call (the production trigger), which does not deadlock. It runs in a daemon thread with a 10 s bound, so a regression fails instead of hanging the suite.tests/utils/test_collect_with_retry.py: unit tests forclose_stream()and both retry loops. They cover:completion_stream, an OpenAI-SDK-shapedStream, and the Responses-API chaincloseattribute that raisescompletion_streamfrom the walk fails 14 testsmain'sretry_utils.py, every new or changed test fails, and the deadlock test fails with the production py-spy stackmainatd3a5995: 844 passed, 1 failed. The failure istest_storage_compat.py::test_uploads_are_plain_payload_signed_not_aws_chunked, which fails identically onmainand is unrelated.snapshot.1016was built from this branch before the review fixes. Still to do: re-run the UN-4136 600-user load test on a snapshot built from the current head and confirm there are no hung executions.Screenshots
Checklist
I have read and understood the Contribution Guidelines.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb