Skip to content

UN-4237 [FIX] Close streamed LLM responses so executor workers no longer deadlock on httpcore's pool lock - #2318

Merged
muhammad-ali-e merged 9 commits into
mainfrom
ccr-b889a1d6-n3vnhm
Oct 9, 2026
Merged

muhammad-ali-e merged 9 commits into
mainfrom
ccr-b889a1d6-n3vnhm

Conversation

@johnyrahul

@johnyrahul johnyrahul commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What

  • New close_stream() helper in unstract/sdk1/utils/retry_utils.py that 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 a close(). That covers:

    • litellm's ModelResponseIterator (Anthropic, Vertex/Gemini, the generic HTTP handler)
    • the OpenAI SDK Stream (OpenAI, Azure, OpenAI-compatible)
    • plain generators (Bedrock)
    • litellm's Responses-API bridge (responses/<model>, plus models litellm routes there such as gpt-5-pro, *-codex and o3-pro), where the response sits at wrapper -> completion_stream -> streaming_response -> response

    It 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 by LLM.complete() when streaming) and iter_with_retry (used by LLM.stream_complete()) now call it in a finally: 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() and iter_with_retry document that a caller who stops early must close the generator, e.g. with contextlib.closing(...).

Why

  • Executor workers hung "in the LLM call" under load: 28 of 28 hung executions in the 7 Oct 600-user run, and 1.1% of executions in the 6 Oct run. py-spy showed they were deadlocked on their own lock inside httpcore's connection pool, not waiting on the network (UN-4237).
  • LiteLLM's sync CustomStreamWrapper has no close(). 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 inside ConnectionPool.handle_request, the finaliser's PoolByteStream.close() waited on the same non-reentrant threading.Lock the thread already held. The worker hung forever, with its lease still renewing.
  • sdk1's existing getattr(gen, "close", None) on the error path was a no-op for these streams.
  • This is not fixed upstream. litellm 1.104.0, httpcore 1.0.9 and httpx 0.28.1 are the latest releases, and I reproduced the deadlock against them.

How

  • close_stream() releases the pool slot on the calling thread, outside any pool lock. Closing the line generator propagates GeneratorExit down to PoolByteStream.close(); closing an httpx.Response closes its stream directly.
  • In both retry loops, the explicit close is called from a finally around each attempt. The time.sleep moved after the try so 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)

  • Low risk. Chunks are fully collected before the close, and stream_chunk_builder only reads the collected chunks, so responses are unchanged. test_anthropic_stream_complete.py still passes.
  • Connections aren't reused after a stream. Closing ends the connection, because litellm leaves unread bytes on it. Before this change the connection leaked until GC and wasn't reused either, so this adds no new connection churn. On paths that already close themselves (OpenAI SDK Stream, a drained Responses stream), the extra close is a no-op.
  • The walk is bounded (depth 3, visited set) and tolerates attributes that raise, so it can't loop or fail on unexpected objects.
  • Not covered: a stream_complete() generator the caller abandons without closing is still finalised by the GC, possibly inside the pool lock. This is now documented on stream_complete. The one known consumer that can abandon it, unstract-cloud's table_extractor plugin, needs contextlib.closing() around its loops; that is a separate change in that repo.

Database Migrations

  • None

Env Config

  • None

Relevant Docs

  • None

Related Issues or PRs

Dependencies Versions

  • No changes (litellm 1.104.0, httpcore 1.0.9, httpx 0.28.1)

Notes on Testing

  • tests/test_stream_connection_release.py runs real litellm against a local server speaking the provider's SSE format, with GC disabled, and checks pool._requests is empty after:
    • Anthropic through LLM.complete(): a completed stream, and a stream failing after content (overloaded_error event)
    • the OpenAI Responses-API bridge (openai/responses/gpt-4o): error after content, early close, and drain
    • forcing gc.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 for close_stream() and both retry loops. They cover:
    • a Bedrock-style generator completion_stream, an OpenAI-SDK-shaped Stream, and the Responses-API chain
    • self-referencing objects, and a close attribute that raises
    • the failed-close warning: its level, the adapter info on the caller's logger, and a traceback that leaves out the propagating provider error
  • Mutation checks:
    • dropping completion_stream from the walk fails 14 tests
    • capping the walk at depth 1 fails 13
    • logging a failed close at DEBUG fails the warning test
    • against main's retry_utils.py, every new or changed test fails, and the deadlock test fails with the production py-spy stack
  • Full sdk1 suite, after merging main at d3a5995: 844 passed, 1 failed. The failure is test_storage_compat.py::test_uploads_are_plain_payload_signed_not_aws_chunked, which fails identically on main and is unrelated.
  • Snapshot snapshot.1016 was 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

…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
@CLAassistant

CLAassistant commented Oct 7, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@johnyrahul johnyrahul self-assigned this Oct 7, 2026

@johnyrahul johnyrahul left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

  1. Spec & intent — See findings #1, #2. The repo states no PR-title convention, so no title check was made.
  2. Architectural fit — Clean
  3. Correctness — See findings #1, #4
  4. Security — Clean. No endpoint, parser, auth or untrusted-input surface. Boundary sweep: 1 sink (the debug log line) traced, clean.
  5. Data integrity — N/A: no persisted data or migrations
  6. Concurrency — See finding #2
  7. 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.
  8. Reliability — See finding #1
  9. Performance & cost — Clean
  10. Observability — See finding #4
  11. Operational safety — Clean
  12. LLM/agent — Clean. stream_chunk_builder reads only the collected chunks.
  13. Testing — See findings #3, #7
  14. Dependencies — N/A: no dependency changes
  15. Code quality — Clean. ruff 0.3.4 check and format --check pass.
  16. Doc & comment accuracy — See findings #2, #5, #6
  17. Cross-document consistency — Clean. The branch is current with main, and the removed "leak sockets until GC" comments are not restated elsewhere.
  18. On-prem — N/A: no chart or on-prem paths
  19. 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_extractor plugin 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.

Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
Comment thread unstract/sdk1/tests/utils/test_collect_with_retry.py
Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
Comment thread unstract/sdk1/tests/test_stream_connection_release.py
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
johnyrahul pushed a commit that referenced this pull request Oct 7, 2026
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
@johnyrahul
johnyrahul marked this pull request as ready for review October 7, 2026 07:12
@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerConfidence Score: 5/5

[High impact] The PR appears safe to merge; no blocking issue was found.

Summary

The PR closes streamed LLM responses after success, failure, and early generator closure. Retry waits now happen after cleanup.

  • Adds a bounded walk through provider stream handles.
  • Adds unit and local-server tests for connection release.
  • Documents the caller's duty to close an unfinished generator.
  • The previous cleanup finding is fixed: reading close now happens inside the guarded block.
  • No new actionable issues were found. Tests were inspected, not run.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Open streamed attempt] --> B[Consume chunks]
  B --> C[Finish, fail, or close early]
  C --> D[Finally: close reachable stream handles]
  D --> E{Retry allowed?}
  E -->|Yes| F[Wait before retry]
  F --> A
  E -->|No| G[Return or raise]
Loading

Reviews (5) · Last reviewed commit: "Merge branch 'main' into ccr-b889a1d6-n3..." · Reviewed by Greptile

Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
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
johnyrahul pushed a commit that referenced this pull request Oct 7, 2026
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 chandrasekharan-zipstack 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.

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.

Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py
Comment thread unstract/sdk1/src/unstract/sdk1/utils/retry_utils.py Outdated
Comment thread unstract/sdk1/tests/test_stream_connection_release.py Outdated
Comment thread unstract/sdk1/tests/test_stream_connection_release.py Outdated
Comment thread unstract/sdk1/tests/utils/test_collect_with_retry.py Outdated
Comment thread unstract/sdk1/tests/utils/test_collect_with_retry.py Outdated
Comment thread unstract/sdk1/tests/utils/test_collect_with_retry.py Outdated
johnyrahul and others added 2 commits October 9, 2026 04:26
…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
johnyrahul pushed a commit that referenced this pull request Oct 9, 2026
…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
@johnyrahul
johnyrahul force-pushed the ccr-b889a1d6-n3vnhm branch from 951a3f4 to b1301c4 Compare October 9, 2026 05:06
@sonarqubecloud

sonarqubecloud Bot commented Oct 9, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
✅ e2e-api-deployment e2e 3 0 0 0 12.5
✅ e2e-coowners e2e 1 0 0 0 1.6
✅ e2e-etl e2e 1 0 0 0 8.2
✅ e2e-login e2e 2 0 0 0 1.1
✅ e2e-prompt-studio e2e 1 0 0 0 9.6
✅ e2e-smoke e2e 2 0 0 0 1.2
✅ e2e-workflow e2e 1 0 0 0 14.3
✅ frontend unit 620 0 0 0 12.4
✅ integration-backend integration 603 0 0 26 59.9
✅ integration-connectors integration 1 0 0 7 9.7
✅ integration-workers integration 164 0 0 1 39.6
❌ ui e2e 0 1 0 0 0.0
✅ unit-backend unit 1478 0 0 1 34.6
✅ unit-connectors unit 82 0 0 0 8.7
✅ unit-core unit 281 0 0 0 2.1
✅ unit-platform-service unit 18 0 0 0 2.0
✅ unit-rig unit 120 0 0 0 3.0
✅ unit-runner unit 12 0 0 0 2.1
✅ unit-sdk1 unit 843 0 0 0 31.5
✅ unit-tool-sidecar unit 8 0 0 0 1.3
✅ unit-workers unit 1412 0 0 1 111.2
TOTAL 5653 1 0 36 366.5

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • platform-key-whoami — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

@muhammad-ali-e
muhammad-ali-e merged commit ed20034 into main Oct 9, 2026
14 checks passed
@muhammad-ali-e
muhammad-ali-e deleted the ccr-b889a1d6-n3vnhm branch October 9, 2026 06:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants