Skip to content

fix(pass-through): stop pass-through route registry growing every reload (PERF-13)#31314

Merged
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_perf13_passthrough_route_leak
Jun 27, 2026
Merged

fix(pass-through): stop pass-through route registry growing every reload (PERF-13)#31314
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_perf13_passthrough_route_leak

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Resolves #19921

Linear ticket

Resolves PERF-13

Pre-Submission checklist

  • I have added meaningful tests
  • 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 5/5

Type

🐛 Bug Fix

Changes

PERF-13 asked whether the 1.81.x performance regression in #19921 still exists and whether the upstream fix in #26082 is relevant. It still exists on litellm_internal_staging (1.91.0), and the pass-through registry leak that #26082 targets is the part that is still unfixed here, so this ports a minimal version of it.

Root cause

The add_deployment_job scheduler re-runs initialize_pass_through_endpoints every 30s when store_model_in_db is on (proxy_server.py:8069). Each cycle re-registers every config and DB pass-through endpoint. An endpoint without a persisted id gets a fresh uuid every reload (pass_through_endpoints.py:2673), so its registry key, formatted as "{id}:{type}:{path}:{methods}", changes each cycle and defeats the if route_key in _registered_pass_through_routes dedup.

The stale-route cleanup then called remove_endpoint_routes(endpoint_key) with that route key, but remove_endpoint_routes matches entries by endpoint_id (pass_through_endpoints.py:2522), so a route key never matched and nothing was ever deleted. _registered_pass_through_routes grew by one entry per route every 30s forever. That unbounded dict is what turns the per-cycle cleanup (O(n) keys, each calling an O(n) scan) and the per-request is_registered_pass_through_route scan in the auth path (route_checks.py:153) into a CPU sink, which matches the reported symptom of one core pinned at 100% and every management API and login getting slower over time.

This is the same root cause as #26082. The other reports in the thread (the router_settings per-request Router and Prometheus registry scan, and the slow spend-logs query) were separate issues already addressed by #20087, #20133, and #20720; this one was not.

Fix

Pop the stale key from the registry directly in O(1). I deliberately did not also strip the path from LiteLLMRoutes.openai_routes the way #26082 does. That list is append-deduped by path (pass_through_endpoints.py:2698, 2737) so it does not grow unboundedly, and on a reload the path is still owned by the live endpoint that was just re-registered under a new id earlier in the same cycle, so removing it would drop a path the live endpoint still needs.

Tests

Added TestStaleRouteCleanupOnReload to the mapped test file. The core test drives the real initialize_pass_through_endpoints across 25 reload cycles with an id-less endpoint and asserts the registry stays at 2 entries; with the old remove_endpoint_routes call it holds 50, so it fails on unfixed code (confirmed by stashing the production change). A second test asserts a departed endpoint is dropped on the next reload, and a third asserts the live route stays resolvable after repeated reloads.

Screenshots / Proof of Fix

The registry size has no HTTP surface, so the leak metric comes from driving the exact reload function the 30s scheduler calls (initialize_pass_through_endpoints) with one id-less endpoint and printing len(_registered_pass_through_routes).

Before (production fix reverted):

  after  1 reload cycle(s): _registered_pass_through_routes size = 2
  after  5 reload cycle(s): _registered_pass_through_routes size = 10
  after 10 reload cycle(s): _registered_pass_through_routes size = 20
  after 20 reload cycle(s): _registered_pass_through_routes size = 40

After (with the fix):

  after  1 reload cycle(s): _registered_pass_through_routes size = 2
  after  5 reload cycle(s): _registered_pass_through_routes size = 2
  after 10 reload cycle(s): _registered_pass_through_routes size = 2
  after 20 reload cycle(s): _registered_pass_through_routes size = 2

Live proxy on the fixed code, with two id-less pass-through endpoints in config (/github-zen and /anthropic-passthrough, include_subpath: true) and store_model_in_db: true so the 30s reload loop runs against real Postgres.

The reload loop fires every 30s and re-registers the endpoints (the leaky code path):

$ grep "adding exact pass through endpoint: /github-zen" litellm.log | grep -oE "^[0-9:]{8}" | sort -u
17:41:36
17:42:06
17:42:36

The id-less pass-through forwards real upstream traffic at startup and stays working across reload cycles:

$ curl -s http://127.0.0.1:4013/github-zen
Mind your words, they are important.

$ curl -s http://127.0.0.1:4013/github-zen        # after 2+ reload cycles
Accessible for all.

The id-less pass-through forwards a real Anthropic request (real provider, real cost):

$ curl -s -X POST http://127.0.0.1:4013/anthropic-passthrough/v1/messages \
    -H "content-type: application/json" -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
    -d '{"model":"claude-haiku-4-5-20251001","max_tokens":40,"messages":[{"role":"user","content":"Reply with exactly: passthrough works"}]}'
{"model":"claude-haiku-4-5-20251001", ... ,"content":[{"type":"text","text":"passthrough works"}], ... }

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the _registered_pass_through_routes dict growing without bound across every 30-second scheduler reload cycle. The root cause was that the stale-entry cleanup called remove_endpoint_routes(endpoint_key), which matched by endpoint_id inside the dict values — never by the route key itself — so nothing was ever deleted; the fix replaces that call with a direct _registered_pass_through_routes.pop(endpoint_key, None).

  • Production change (1 line): In initialize_pass_through_endpoints, the stale-route loop now pops the exact key from the registry in O(1) rather than scanning values by endpoint_id, which never matched because route keys encode a per-cycle UUID. LiteLLMRoutes.openai_routes is intentionally left alone: its append is path-deduped so it cannot grow unboundedly, and on a reload the path is still owned by the freshly re-registered live endpoint.
  • Tests (3 new async tests): TestStaleRouteCleanupOnReload drives initialize_pass_through_endpoints across 25 cycles to assert the registry stays at 2 entries, confirms a departed endpoint is evicted on the next reload, and verifies the live route remains resolvable after repeated reloads. All network/IO is mocked via ExitStack patches.

Confidence Score: 5/5

Safe to merge — the change is a single-line targeted fix to the stale-route cleanup loop, backed by three regression tests that would fail on the unfixed code.

The production change is minimal and precise: one line replaces a call that silently did nothing with a direct dict pop. The root cause analysis in the PR description is accurate and traceable in the code. The three new tests exercise the exact failure scenarios (bounded growth, departed-endpoint removal, live-route survivability) using real function calls against properly mocked I/O, and would catch a regression. No existing tests are modified or weakened. The deliberate decision to leave openai_routes un-cleaned is sound — that list is append-deduped so it cannot grow, and removing the path for a same-cycle re-registration would break the live endpoint.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/pass_through_endpoints/pass_through_endpoints.py Replaces the broken remove_endpoint_routes(endpoint_key) call (which matched by endpoint_id, not by key) with a direct O(1) pop on the registry dict, stopping the unbounded growth of _registered_pass_through_routes across 30s reload cycles.
tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py Adds TestStaleRouteCleanupOnReload with three well-structured async tests covering: (1) registry stays bounded across 25 reload cycles for id-less endpoints, (2) departed endpoints are removed on the next reload, and (3) the live route stays resolvable after repeated reloads. All network/IO is mocked via ExitStack patches.

Reviews (1): Last reviewed commit: "fix(pass-through): remove stale routes b..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a CPU-sink regression where _registered_pass_through_routes grew by two entries every 30-second reload cycle for any id-less pass-through endpoint, because the stale-key cleanup called remove_endpoint_routes(route_key) — a function that matches on endpoint_id, not on a full route key — so it never matched anything.

  • Core fix (one line change): the cleanup loop now calls _registered_pass_through_routes.pop(endpoint_key, None) directly, which is O(1) and correct. The LiteLLMRoutes.openai_routes cleanup is intentionally skipped because that path is append-deduped and is still valid for the live endpoint re-registered in the same cycle.
  • Tests: three new async regression tests drive initialize_pass_through_endpoints through multiple reload cycles and assert the registry stays bounded, stale entries are evicted, and live routes remain resolvable. All tests use mocks and make no real network calls.

Confidence Score: 5/5

Safe to merge. The change is minimal and surgical, touching only the stale-key eviction path in the 30-second reload loop and adding purely additive tests.

The root cause analysis is accurate, the one-line fix is correct, and the three new tests directly exercise the broken path and would fail against the pre-fix code. The intentional omission of openai_routes cleanup for departed auth-required endpoints is well-documented and represents a pre-existing gap that was already broken (the old call never matched). No backward-incompatible behavior changes, no security implications, no new DB queries in the critical path.

No files require special attention. Both changed files are straightforward and well-reasoned.

Important Files Changed

Filename Overview
litellm/proxy/pass_through_endpoints/pass_through_endpoints.py Core fix: replaces the broken remove_endpoint_routes(endpoint_key) call with a direct O(1) _registered_pass_through_routes.pop(endpoint_key, None), correctly eliminating the registry-growth leak for id-less endpoints. Cleanup of LiteLLMRoutes.openai_routes for departed auth-required endpoints is intentionally omitted and was already broken before this PR.
tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py Adds three focused regression tests for the reload-leak fix: bounded registry across 25 cycles, departed endpoint removal, and live-route survival — all using mocks with no real network calls, satisfying the no-real-network rule for this test directory.

Reviews (2): Last reviewed commit: "fix(pass-through): remove stale routes b..." | Re-trigger Greptile

@codecov

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yassin-berriai yassin-berriai enabled auto-merge (squash) June 25, 2026 15:30
@yassin-berriai yassin-berriai force-pushed the litellm_perf13_passthrough_route_leak branch 2 times, most recently from 29daf6d to 9bbddd3 Compare June 26, 2026 07:01
…rowing every reload

The 30s add_deployment_job re-runs initialize_pass_through_endpoints, which
re-registers every config/DB pass-through endpoint. Endpoints without a
persisted id get a fresh uuid each cycle, so their route key
("{id}:{type}:{path}:{methods}") changes every reload. The stale-route cleanup
called remove_endpoint_routes(route_key), but that helper matches entries by
endpoint_id, so it never matched a route key and never deleted anything. The
registry grew by one entry per route per reload, turning the per-cycle cleanup
and the per-request is_registered_pass_through_route scan into a CPU sink that
eventually pins a core and slows every endpoint.

Pop the stale key from the registry directly in O(1). openai_routes is left
alone: its append is path-deduped and the path is still owned by the live
endpoint re-registered under a new id in the same cycle.

Resolves PERF-13
@yassin-berriai yassin-berriai force-pushed the litellm_perf13_passthrough_route_leak branch from 9bbddd3 to 3f58e73 Compare June 26, 2026 20:02
@yassin-berriai yassin-berriai merged commit 80d3b69 into litellm_internal_staging Jun 27, 2026
122 checks passed
@yassin-berriai yassin-berriai deleted the litellm_perf13_passthrough_route_leak branch June 27, 2026 07:34
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]: Significant performance regression after upgrading from 1.80.5 to 1.81.x (UI + API slowness)

3 participants