Skip to content

fix(pass-through): remove stale routes by key to prevent unbounded registry growth - #24846

Closed
silencedoctor wants to merge 1 commit into
BerriAI:mainfrom
silencedoctor:fix/pass-through-registry-unbounded-growth
Closed

fix(pass-through): remove stale routes by key to prevent unbounded registry growth#24846
silencedoctor wants to merge 1 commit into
BerriAI:mainfrom
silencedoctor:fix/pass-through-registry-unbounded-growth

Conversation

@silencedoctor

@silencedoctor silencedoctor commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #24833

Pre-Submission checklist

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Type

🐛 Bug Fix

Changes

One-line fix in initialize_pass_through_endpoints (line ~2294):

# Before — scans by endpoint_id, never matches because UUID changes every cycle
InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_key)

# After — pops the stale route key directly in O(1)
_registered_pass_through_routes.pop(endpoint_key, None)

Root cause: When pass-through endpoints stored in DB have no id field, each 30s reload generates a new UUID. The old cleanup (remove_endpoint_routes) compared the full route key against endpoint_id (UUID only) — they never matched, so stale entries were never deleted. The dict grew without bound, making cleanup O(n²) and eventually pinning CPU at 100%.

Fix: Replace the remove_endpoint_routes() call with a direct dict.pop() on the route key, which removes the stale entry in O(1).

Tests added

3 unit tests in TestRemoveStaleEndpointRoute:

  • test_pop_removes_stale_route_by_key — verifies O(1) removal
  • test_pop_noop_for_unknown_key — verifies no-op for nonexistent keys
  • test_registry_does_not_grow_across_reload_cycles — core regression test: simulates 50 reload cycles with changing UUIDs and asserts the registry stays at constant size

@vercel

vercel Bot commented Mar 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 31, 2026 7:06am

Request Review

@codspeed-hq

codspeed-hq Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing silencedoctor:fix/pass-through-registry-unbounded-growth (11cecb3) with main (08be1e5)

Open in CodSpeed

…registry growth

When pass-through endpoints stored in DB have no `id` field, each 30s
reload generates a new UUID.  The old cleanup (`remove_endpoint_routes`)
scanned by `endpoint_id` (UUID only) and never matched the full route key,
so stale entries were never deleted — the dict grew without bound, O(n²).

Fix: replace `remove_endpoint_routes(endpoint_key)` with
`_registered_pass_through_routes.pop(endpoint_key, None)` to remove the
stale route key directly in O(1).

Fixes BerriAI#24833

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real and severe CPU/memory regression (#24833) where the periodic pass-through endpoint reload caused _registered_pass_through_routes to grow unboundedly. The root cause was that the old cleanup called remove_endpoint_routes(endpoint_key) passing the full route key (e.g. "uuid:exact:/path:GET,POST") to a method that searched for value["endpoint_id"] == endpoint_id — so no entry ever matched and no stale routes were ever deleted. The fix replaces this with a direct _registered_pass_through_routes.pop(endpoint_key, None), which is O(1) and correct.

Key findings:

  • The primary fix is correct and directly addresses the reported CPU spike.
  • Incomplete cleanup (P1): The PR description claims the fix "also properly cleans up LiteLLMRoutes.openai_routes entries", but the code does not do this. For auth-enabled subpath endpoints, stale paths continue to accumulate in LiteLLMRoutes.openai_routes.value across reload cycles, recreating the same class of unbounded growth in a second data structure iterated by route_checks.py on every request.
  • Shallow test coverage (P2): The new regression tests directly manipulate the module-level dict and call dict.pop() themselves, rather than invoking initialize_pass_through_endpoints. They validate Python dict semantics rather than the actual production cleanup path.

Confidence Score: 4/5

Safe to merge for the primary CPU/memory fix, but the incomplete openai_routes cleanup means the same class of unbounded growth persists for auth-enabled subpath endpoints.

The core registry growth fix is correct and well-targeted. One P1 gap remains: the PR claims to clean up LiteLLMRoutes.openai_routes but does not, leaving a parallel leak for auth-enabled subpath endpoints that will cause the same O(n) per-request degradation in route_checks.py over time.

The cleanup loop in initialize_pass_through_endpoints (lines 2292–2298 of pass_through_endpoints.py) needs the openai_routes removal logic added.

Important Files Changed

Filename Overview
litellm/proxy/pass_through_endpoints/pass_through_endpoints.py Replaces the broken remove_endpoint_routes(endpoint_key) call with dict.pop(endpoint_key, None) — correctly fixing O(1) registry cleanup; however, the corresponding LiteLLMRoutes.openai_routes cleanup promised in the PR description is absent, leaving a parallel unbounded-growth issue for auth-enabled subpath endpoints.
tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py Adds three regression tests for the registry-growth bug, but all tests directly call dict.pop() on the module-level dict rather than going through initialize_pass_through_endpoints, so they verify Python dict semantics rather than the production code path.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[periodic reload every ~30s] --> B[initialize_pass_through_endpoints]
    B --> C[snapshot current registry keys]
    B --> D[register each DB and config endpoint]
    D --> E{route already registered?}
    E -- yes --> F[update metadata in registry]
    E -- no --> G[add_exact_path_route or add_subpath_route]
    G --> H[write to _registered_pass_through_routes]
    G --> I{auth enabled?}
    I -- yes --> J[append path and wildcard to openai_routes list]
    B --> K[cleanup loop: stale keys not in visited set]
    K --> L[_registered_pass_through_routes.pop key — FIXED O1]
    K --> M[openai_routes cleanup — MISSING unbounded growth]
    style M fill:#f96,stroke:#c33
    style L fill:#6f6,stroke:#393
Loading

Reviews (1): Last reviewed commit: "fix(pass-through): pop stale route key d..." | Re-trigger Greptile

Comment on lines 2292 to +2298
for endpoint_key in registered_pass_through_endpoints:
if endpoint_key not in visited_endpoints:
InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_key)
_registered_pass_through_routes.pop(endpoint_key, None)
verbose_proxy_logger.debug(
"Removed stale pass-through route from registry: %s",
endpoint_key,
)

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.

P1 Missing LiteLLMRoutes.openai_routes cleanup

The PR description explicitly states the fix "also properly cleans up LiteLLMRoutes.openai_routes entries (both exact paths and /* wildcard paths for subpath routes), matching the behavior of the existing remove_endpoint_routes." However, the actual implementation only pops from _registered_pass_through_routes without touching LiteLLMRoutes.openai_routes.value.

For auth-enabled endpoints registered with include_subpath=True, each reload cycle appends the path and its /* wildcard to openai_routes (lines 2180–2216), but the stale entries are never removed in this loop. This creates the same class of unbounded growth — just in openai_routes instead of _registered_pass_through_routes.

route_checks.py iterates LiteLLMRoutes.openai_routes.value on every request for fuzzy matching (line 317), so the accumulation will cause the same O(n) per-request degradation the PR aims to fix for _registered_pass_through_routes.

The existing remove_endpoint_routes (used for explicit delete/update flows) shows the intended cleanup pattern. The stale-route removal loop should also inspect the popped route_info and mirror that cleanup:

for endpoint_key in registered_pass_through_endpoints:
    if endpoint_key not in visited_endpoints:
        route_info = _registered_pass_through_routes.pop(endpoint_key, None)
        if route_info is not None:
            path = route_info.get("path")
            if isinstance(path, str):
                openai_routes_list = LiteLLMRoutes.openai_routes.value
                if path in openai_routes_list:
                    openai_routes_list.remove(path)
                if route_info.get("type") == "subpath":
                    wildcard_path = path.rstrip("/") + "/*"
                    if wildcard_path in openai_routes_list:
                        openai_routes_list.remove(wildcard_path)
        verbose_proxy_logger.debug(
            "Removed stale pass-through route from registry: %s",
            endpoint_key,
        )

Comment on lines +2495 to +2555
def test_pop_removes_stale_route_by_key(self):
"""Stale route is removed in O(1) by its route key via dict.pop()."""
route_key = "old-uuid:exact:/my-endpoint:GET,POST"
_registered_pass_through_routes[route_key] = {
"endpoint_id": "old-uuid",
"path": "/my-endpoint",
"type": "exact",
}

_registered_pass_through_routes.pop(route_key, None)

assert route_key not in _registered_pass_through_routes

def test_pop_noop_for_unknown_key(self):
"""pop() with default None does not raise for nonexistent keys."""
_registered_pass_through_routes["keep-me:exact:/a:GET"] = {
"endpoint_id": "keep-me",
"path": "/a",
"type": "exact",
}

_registered_pass_through_routes.pop("nonexistent:exact:/b:GET", None)

assert len(_registered_pass_through_routes) == 1

def test_registry_does_not_grow_across_reload_cycles(self):
"""Simulate multiple reload cycles with changing UUIDs.

Core regression test: without the fix the registry grows by N entries
every cycle; with the fix it stays constant.
"""
path = "/vertex-passthrough"
methods_str = "GET,POST"
num_cycles = 50

for cycle in range(num_cycles):
old_keys = list(_registered_pass_through_routes.keys())

new_uuid = f"uuid-{cycle}"
new_exact_key = f"{new_uuid}:exact:{path}:{methods_str}"
new_subpath_key = f"{new_uuid}:subpath:{path}:{methods_str}"

_registered_pass_through_routes[new_exact_key] = {
"endpoint_id": new_uuid,
"path": path,
"type": "exact",
}
_registered_pass_through_routes[new_subpath_key] = {
"endpoint_id": new_uuid,
"path": path,
"type": "subpath",
}

visited = {new_exact_key, new_subpath_key}

# Clean up stale routes — the fix: pop by key in O(1)
for key in old_keys:
if key not in visited:
_registered_pass_through_routes.pop(key, None)

# After 50 cycles, only the last cycle's 2 keys should remain

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.

P2 Tests exercise dict operations, not the production code path

All three new tests directly manipulate _registered_pass_through_routes and call dict.pop() on it themselves. They do not call initialize_pass_through_endpoints or any production function. For example, test_pop_removes_stale_route_by_key invokes _registered_pass_through_routes.pop(route_key, None) directly — this only verifies that Python's dict works, not that the production code actually uses it.

The core regression test test_registry_does_not_grow_across_reload_cycles re-implements the cleanup loop inline rather than invoking the real initialize_pass_through_endpoints. If someone later refactors that function (e.g., accidentally re-introduces the old O(n) scan), the tests will continue to pass.

Consider mocking out the DB + FastAPI app interactions and calling initialize_pass_through_endpoints (or at minimum the cleanup slice of it) so the tests actually cover the production code path that was broken.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Already fixed in 7b2a7e0 — result_rows = [] was moved to the top of Section 5 (before the message-building loop) and the duplicate initialization in Section 6 was removed.

@silencedoctor

Copy link
Copy Markdown
Contributor Author

Closing in favor of #26082, which contains the same one-line fix cleanly rebased onto current main. The branch here had drifted significantly behind main and the PR sat un-reviewed for 3 weeks despite Greptile-confirmed correctness.

New PR: #26082
New issue: #26081

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.

[Bug]: Pass-through endpoint registry grows unbounded causing CPU to reach 100%

2 participants