fix(aiohttp): dispose recycled client sessions deterministically - #33428
Conversation
Greptile SummaryFixes intermittent
Confidence Score: 5/5Safe to merge — the change is a targeted disposal fix inside The disposal logic covers all three session lifecycle cases correctly. The private Files Needing Attention: No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/llms/custom_httpx/aiohttp_transport.py | Adds _close_recycled_session helper covering three session-lifecycle cases (same loop, foreign running loop, dead loop), wires it into all three replacement sites, and holds strong task references in a class-level registry to prevent GC of pending close tasks. |
| tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py | Adds 8 targeted regression tests covering the fallback branch, dead-loop disposal, strong-reference registry, foreign-running-loop threadsafe close, cancelled-future callback guard, and concurrent-replacement isolation — all mock-only, no real network calls. |
Reviews (4): Last reviewed commit: "fix(aiohttp): guard threadsafe close cal..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
|
|
@greptileai please review the current head 2c22d93 |
2c22d93 to
214c0bc
Compare
|
Rebased onto current staging (3c2264c) to clear a conflict with #34962, which refactored the same replacement sites behind @greptileai please review the current head 214c0bc |
LiteLLMAiohttpTransport replaced its cached aiohttp.ClientSession on
loop-mismatch, loop-inspection failure, and "Session is closed" retry
without reliably closing the previous session:
- the close task from asyncio.create_task() was never referenced, so
it could be garbage-collected before running;
- the (RuntimeError, AttributeError) fallback branch replaced the
session without closing it at all;
- sessions bound to a closed event loop were abandoned to the GC
("rely on GC"), and sessions bound to a loop running in another
thread were closed from the wrong loop.
Replaced sessions surfaced as intermittent "Unclosed client session" /
"Unclosed connector" errors from the event-loop exception handler at
GC time.
_close_recycled_session() now covers the three lifecycles a recycled
session can be in: same-loop closes keep a strong task reference until
completion; sessions owned by a loop running elsewhere are closed on
their own loop via run_coroutine_threadsafe; sessions whose loop is
gone are disposed synchronously through the connector teardown that
aiohttp's own finalizer uses, which releases pooled connections and
silences the finalizer warnings.
Fixes #24230
214c0bc to
3140976
Compare
|
Pushed a lint-budget fix folded into the commit that introduced the registry: the class-level @greptileai please review the current head 3140976 |
|
Note for whoever picks this up: the Leaving the ignore list alone here since the violation belongs to staging; it needs a fix on staging or an ignore entry in its own PR |
16507f1
into
litellm_internal_staging
Relevant issues
Fixes #24230
Internal mirror of #32003 (credit to @anmolg1997) retargeted at litellm_internal_staging. The original PR targets litellm_oss_staging and has been open since July 3; this brings the fix into internal staging directly. Builds on the analysis in #24231
Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Verbatim repro script from #24230, re-run after the rebase with the import path verified first (
imported: .../worktree/litellm/llms/custom_httpx/aiohttp_transport.py)Before, at the current litellm_internal_staging tip this branch is now based on (3c2264c), with the fix confirmed absent (
grep -c _close_recycled_sessionreturns 0):After, at this branch (214c0bc):
Mutation check, both invariants this branch has to hold at once. Restoring the unfixed source and re-running the test file makes the regression tests added here fail, and they pass with the fix
Dropping the
self._owns_sessionguard that gates disposal makes #34962's shared-session test fail, so the disposal cannot silently start closing a session this transport does not ownType
🐛 Bug Fix
Changes
LiteLLMAiohttpTransportreplaced its cachedaiohttp.ClientSessionin three places without reliably closing the previous session: the loop-mismatch recycle discarded theasyncio.create_task(old_session.close())reference so the close task could be garbage-collected before it ran, the(RuntimeError, AttributeError)fallback branch replaced the session without closing it at all (the exact repro in #24230), and cross-loop sessions were either abandoned to GC or closed from the wrong loop. Replaced sessions surfaced as intermittentUnclosed client session/Unclosed connectorERROR-severity asyncio log entries at GC timeThis PR adds one disposal helper,
_close_recycled_session(), used by all three replacement sites (loop-mismatch, fallback branch, and the "Session is closed" retry inhandle_async_request). It handles the three lifecycles a recycled session can be in: on the current running loop it schedules the async close and keeps a strong reference in a class-level registry until the task completes; on a loop running in another thread it hands the close to the session's own loop withasyncio.run_coroutine_threadsafe; on a closed or stopped loop it disposes synchronously through the connector teardown aiohttp's own finalizer uses, which releases pooled connections and flips theclosedflags the finalizers checkNo public API changes; the happy path (valid session on the current loop) is untouched
Commit 214c0bc addresses the Greptile finding:
_on_threadsafe_close_donenow returns early when the future was cancelled (the foreign loop stopped before the handed-off close ran), matching the guard its sibling_on_close_task_donealready had, sofuture.exception()cannot raiseCancelledErrorout of the callback machinery. Comes with a regression test that fails on the unguarded callbackRebase onto current staging
The branch is rebased onto 3c2264c to clear a conflict with #34962, which landed in the same code path after this PR was opened. That PR routed every session replacement through a shared
_rebuild_session()helper so a rebuilt session keeps the connector's keep-alive config, and added an ownership guard so a transport handed the proxy's shared session leaves it open on rebuildThe resolution keeps
_rebuild_session()at every replacement site and gates each_close_recycled_session()call onself._owns_session, read before_rebuild_session()claims ownership for the replacement. Disposal therefore applies to sessions this transport owns, which is every session it built itself, and #34962's shared-session guarantee still holds. The two mutation runs above show both directions are load-bearing: reverting the disposal fails 6 of the tests here, and dropping the ownership guard fails #34962'stest_stale_loop_rebuild_does_not_close_unowned_sessionFinal Attestation