Skip to content

fix: avoid leaking every Router instance via _cached_get_model_group_info - #41582

Closed
kratos0718 wants to merge 1 commit into
BerriAI:mainfrom
kratos0718:fix/router-cached-model-group-info-leak
Closed

kratos0718 wants to merge 1 commit into
BerriAI:mainfrom
kratos0718:fix/router-cached-model-group-info-leak

Conversation

@kratos0718

Copy link
Copy Markdown

TLDR

Problem this solves:

  • Router leaks every instance once it calls a cached helper method
  • Happens even after calling Router's own documented cleanup, discard()
  • cache_clear() on that helper also wipes every other Router's cache

How it solves it:

  • Moves the cache from the class to the instance, matching a sibling method
  • Old Router instances are now collectible once nothing else references them
  • cache_clear() now only clears the calling Router's own entries

User Flow

This is an SDK-level object lifecycle bug, not something visible in a single request/response pair, so it's written from the seat of a developer embedding litellm.Router in their own long-running process (a proxy wrapper, a multi-tenant service creating one Router per tenant, or a config-reload path that rebuilds the Router).

Before: memory grows without bound as old Router instances pile up

  1. The developer builds a Router with a model list and serves at least one request through it (any request touches the same cached helper that set_response_headers calls on every request)
  2. They call router.discard(), the method Router itself documents as its cleanup step, then drop every other reference to the Router
  3. They inspect the process (a heap snapshot, or just gc.get_referrers on the Router object) and find it is still reachable, held by the cached helper's own internal cache
  4. Every Router they ever constructed this way stays alive for the life of the process, even though discard() was called correctly

After: a discarded Router with no other references is actually freed

  1. Same steps: build a Router, serve a request through it, call discard(), drop the reference
  2. The Router is now collectible; a heap snapshot no longer shows it, and gc.get_referrers returns nothing
  3. Only Routers still in active use hold memory; ones a developer has already cleaned up do not accumulate

Relevant issues

Affected release

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

This is a memory lifecycle bug: there's no request or response that looks different before and after, since the leak only shows up in what stays reachable on the heap after a Router is discarded. A live-proxy curl session would show identical output either way. The honest proof here is a direct reproduction against the Router object itself, run at each commit with uv run python3 -c "...", not a pytest suite.

Before (4b368bf)

Router leaks after discard()

  1. Ran: uv run python3 -c "import litellm, weakref, gc; r = litellm.Router(model_list=[{'model_name': 'gpt-4', 'litellm_params': {'model': 'gpt-4', 'api_key': 'fake'}}]); r._cached_get_model_group_info('gpt-4'); r.discard(); ref = weakref.ref(r); del r; gc.collect(); print(ref() is None)"
  2. Output: False, the Router is still reachable after discard() and every other reference are dropped

After (7f1296f)

Router leaks after discard()

  1. Ran the same command
  2. Output: True, the Router is collected once discard() has run and nothing else references it

Type

🐛 Bug Fix
✅ Test

Caveats (if any)

Low

  • Proof of fix uses a direct object-lifecycle reproduction instead of a live proxy session, since the bug has no request/response signature to curl against

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

_cached_get_model_group_info is decorated with @lru_cache directly on
the Router class. That keys the cache on self as well as model_group, so
every Router that ever calls it stays reachable through the cache for
the life of the process, even after discard() (the router's own
documented cleanup method) and every other reference are dropped.
set_response_headers calls it on every request, so this hits any
process that constructs more than one Router over its lifetime, for
example once per tenant or on every proxy config reload.

The sibling method cached_deployment_model_info already wraps the bound
method in __init__ instead of decorating it at class scope, which keeps
the cache scoped to the instance. This does the same thing for
_cached_get_model_group_info. As a side effect it also fixes
cache_clear() on this cache: it currently clears every Router's entries
process-wide instead of just the one being invalidated, since it is
still the same shared class-level cache object.

Added a regression test that constructs a Router, calls the cached
method, calls discard(), drops the reference, and asserts the Router is
collected. It fails against the current code and passes with this fix.
@kratos0718
kratos0718 requested a review from a team September 17, 2026 08:41
@codspeed

codspeed Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing kratos0718:fix/router-cached-model-group-info-leak (7f1296f) with main (4b368bf)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge and fixes the Router retention issue without changing model-group lookup behavior

Summary

The PR moves model-group-info caching from a class-level wrapper to an instance-owned wrapper, preventing cached keys from retaining discarded Router instances. It adds a focused weak-reference regression test proving collection after cleanup

Reviews (1) · Last reviewed commit: "fix: Router._cached_get_model_group_info..."

@kratos0718 kratos0718 changed the title fix: Router._cached_get_model_group_info leaks every Router instance fix: avoid leaking every Router instance via _cached_get_model_group_info Sep 17, 2026
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mateo-berri

Copy link
Copy Markdown
Contributor

The Router leak is fixed on main by #49513, which gives each router its own cache. Reopen if it still happens or something is missing

@mateo-berri mateo-berri closed this Nov 9, 2026
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.

2 participants