Skip to content

fix(proxy): stop model writes 500ing on another pod's delete - #35400

Merged
ryan-crabbe-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_fix_model_write_reload_guard_false_positive
Aug 1, 2026
Merged

fix(proxy): stop model writes 500ing on another pod's delete#35400
ryan-crabbe-berri merged 3 commits into
litellm_internal_stagingfrom
litellm_fix_model_write_reload_guard_false_positive

Conversation

@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor

TLDR

High level flow for the user:

  • Delete a model, then create one seconds later
  • The create no longer 500s when it saved fine

High level flow on a technical level:

  • The create's reload evicts a model another pod deleted
  • The guard read that correct eviction as damage
  • Now it ignores ids the db no longer wants

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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

The failure is a race between two pods, so the honest proof is the stage occurrence plus a test that reproduces the exact id sets. A single-pod local proxy cannot hit it: the delete and the create land on the same process, so the router is already correct when the create snapshots it, which is also why the same test passes on some stage runs and fails on others

From the stage e2e run on 2026-07-30 12:42 UTC, ns litellm, at stage image commit 38f2e023f1. The model was created and deleted by the suite minutes apart, and the delete landed on a different backend pod than the create:

13:51:15.534Z  55jwc  "POST /model/new HTTP/1.1" 200 OK        <- model a50fa33f... created here
13:51:31.401Z  b89kn  "POST /model/delete HTTP/1.1" 200 OK     <- deleted on a DIFFERENT pod
13:51:32.082Z  55jwc  ERROR add_new_model(): ... Previously served model id(s) ['a50fa33f-...']
13:51:32.086Z  55jwc  "POST /model/new HTTP/1.1" 500 Internal Server Error

0.68 seconds separate the delete from the 500. Pod 55jwc had not yet polled the delete, so its before snapshot still listed a50fa33f; the reload the create triggered read the db, correctly evicted the row, and the guard reported that eviction as collateral damage. The written model itself was live the whole time, which is why the message uses the generic "degraded this pod's serving state" clause rather than naming a model that failed to serve

The adjacency is the discriminator, and it holds across runs. From 2026-07-29 12:32Z, same signature, 200 when the pair shares a pod and 500 when it does not:

12:32:06.255 delete jlfd5 -> 12:32:06.958 new jlfd5  200   (same pod)
12:32:13.938 delete jlfd5 -> 12:32:14.763 new m7gg8  500   (cross-pod)
12:32:22.328 delete jlfd5 -> 12:32:23.155 new m7gg8  500   (cross-pod)
12:32:31.013 delete m7gg8 -> 12:32:31.985 new m7gg8  200   (same pod)
12:32:38.007 delete m7gg8 -> 12:32:38.762 new jlfd5  500   (cross-pod)

There is no reload failure behind any of these. Paging every non-OTel log line on the affected pods across the failure windows returns exactly one ERROR each, the guard's own exception; no Error upserting deployment, no Error creating deployment, no traceback from the reload stack

The new test reproduces the id sets directly and fails without the fix:

$ python -m pytest tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py -q -k TestWriteSurfacesReloadDrop
# with the fix reverted to `collateral = tuple(sorted(dropped))`
FAILED ...::TestWriteSurfacesReloadDrop::test_a_model_the_db_no_longer_has_is_not_collateral
1 failed, 2 passed

# with the fix in place
3 passed

Full runs at commit e8fc9a92f3:

$ python -m pytest tests/test_litellm/proxy/management_endpoints/ -q
2226 passed, 1 skipped

$ python -m pytest tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py \
    tests/test_litellm/proxy/test_update_llm_router_resilience.py \
    tests/test_litellm/proxy/proxy_server/test_proxy_config.py -q
211 passed

$ python -m pytest tests/test_litellm/proxy/test_proxy_server.py -q
285 passed

(One unrelated pre-existing failure, test_mcp_token_opens_sealed_passthrough_code_and_exchanges_with_minted_client, reproduces with these source changes reverted; it reads PROXY_BASE_URL from a local .env and is not present in CI.)

ruff check and ruff format --check pass on both changed source files

Type

🐛 Bug Fix

Changes

_delete_deployment already builds the set that settles this: the ids the db and config still want after reconciling (proxy_server.py). It used that set to evict and then discarded it, returning a delete count no proxy caller read. It now returns the set instead, and _update_llm_router, add_deployment and clear_cache pass it up to the model-write endpoints, which hand it to raise_if_reload_degraded_serving as still_desired

reload_serving_verdict intersects the drop set with it: dropped if still_desired is None else dropped & still_desired. An id the db no longer has was deleted deliberately, so the reload dropping it is the reconcile working rather than damage. Where no reconcile ran the set is None and every drop is reported exactly as before, so a genuinely broken reload still fails the write; there is a test for that case

All four guard call sites are covered (create, the two update paths, and the patch path), because they all reach the reload through add_deployment and all share the same race

Backwards-incompatible detail, flagging explicitly: ProxyConfig._delete_deployment now returns frozenset[str] | None instead of int. It is private, its single proxy caller ignored the return, and the tests that asserted the count already assert the eviction calls themselves, so the coverage they provided is preserved. add_deployment and clear_cache previously returned None implicitly, so gaining a return value is additive and every existing caller is unaffected

Not addressed here, deliberately: the 30-second propagation delay itself. Other pods still learn about a write on their own poll interval, and a fixed sleep in the e2e harness was considered and rejected since it would have added roughly half an hour to the suite while leaving the customer-facing 500 in place

A model write judges the reload it triggers by diffing this pod's router before and
after, and reports anything that stopped serving as damage. On a pod that has not yet
polled a delete another pod made, the snapshot still lists that model; the reload then
evicts it because the db no longer has it, and the guard reads its own correct
reconcile as degradation. The row is written and served, but the caller gets a 500.

Since propagation between pods is a 30s db poll, any delete followed by a create
inside that window can land on a pod that has not caught up, so a delete-then-create
pair returns 500 whenever the two requests hit different pods.

_delete_deployment already computes exactly the set that settles it: the ids the db
and config still want. Thread it up through _update_llm_router, add_deployment and
clear_cache to the verdict, and intersect the drop set with it so an id the db no
longer has stops counting as collateral. Where no reconcile ran the set is None and
every drop is still reported, so a genuinely broken reload is caught as before.

_delete_deployment now returns that set instead of a delete count; the count had no
callers in the proxy, and the tests asserting it already assert the eviction calls.
@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR prevents model-write endpoints from treating deliberate cross-pod model eviction as reload degradation

  • Propagates the reconciled DB and config model ID set through router reload helpers
  • Filters collateral deployment drops against IDs that remain desired
  • Adds regression coverage for deliberate eviction, genuine degradation, and unknown reconciliation state

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/model_management_endpoints.py Propagates reconciled model IDs to reload verdicts and excludes intentionally deleted models from collateral degradation
litellm/proxy/proxy_server.py Returns the desired deployment ID set from reconciliation while preserving None when reconciliation does not run
tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py Adds focused regression coverage and resolves the prior commentary concern by placing necessary context in the test docstring
tests/test_litellm/proxy/proxy_server/test_proxy_config.py Updates reconciliation tests to verify the desired-ID return contract
tests/test_litellm/proxy/test_proxy_server.py Preserves deployment-eviction assertions while validating desired IDs and empty reconciliation results
tests/test_litellm/proxy/test_update_llm_router_resilience.py Verifies reconciliation failures return an unknown desired set and successful reconciliation returns DB and config IDs

Reviews (3): Last reviewed commit: "test: fix clear_cache mock return type i..." | Re-trigger Greptile

…assertions

Greptile flagged the inline comments against the repo's no-new-comments rule. The
case-by-case context moves into the test docstring, and the two return-contract
assertions carry their reasoning as failure messages instead.
@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...management_endpoints/model_management_endpoints.py 92.30% 1 Missing ⚠️
litellm/proxy/proxy_server.py 90.90% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_model_write_reload_guard_false_positive (2d5754d) with litellm_internal_staging (fa56283)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (3083c55) during the generation of this report, so fa56283 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@ryan-crabbe-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re review

@devin-ai-integration

Copy link
Copy Markdown
Contributor

QA'd this before and after in a two-pod setup, since the race needs a second pod

Two proxy processes on 4001 and 4002 share one Postgres, which is what two replicas behind one Service look like to the db. proxy_config_reload_interval_seconds: 600 in general_settings holds the propagation window open so the pod handling the write reliably has not yet polled the other pod's delete; on stage that window is 30s and the failure lands inside it. Before is fa56283806 (the merge base) and after is 2d5754d928 (PR head). Models point at a real Anthropic key, and every write is followed by a real completion through the pod that handled it

Cross-pod delete, then POST /model/new on the other pod. Before:

$ curl -s "$B/model/delete" $H -d "{\"id\": \"$victim\"}"
{"message":"Model: f7211f74-23ae-495b-b545-64feb8c5ab4c deleted successfully"}   200

$ curl -s "$A/model/new" $H -d '{"model_name":"qa-followup","litellm_params":{...}}'
{"error":{"message":"Model create was saved to the database, but the reload it triggered
 degraded this pod's serving state. Previously served model id(s)
 ['f7211f74-23ae-495b-b545-64feb8c5ab4c'] are also no longer being served by this pod. ..."}}
500

$ curl -s "$A/model/info" $H            # the write the caller was told failed
[('config-fake', '7fcb...'), ('qa-followup', '6c413250-ee62-4a6e-a787-3ff51070e822')]
$ curl -s "$A/v1/chat/completions" $H -d '{"model":"qa-followup", ...}'
{"choices":[{"message":{"content":"pong", ...}}], "usage":{"total_tokens":19}}   200

The 500 names the id another pod deleted, while the model the caller actually wrote is in /model/info and answering live traffic on that same pod. After, identical sequence:

$ curl -s "$A/model/new" $H -d '{"model_name":"qa-followup","litellm_params":{...}}'
{"model_id":"370224b7-1592-4b0a-b751-5a8629dfa8b8","model_name":"qa-followup", ...}
200
$ curl -s "$A/v1/chat/completions" $H -d '{"model":"qa-followup", ...}'
{"choices":[{"message":{"content":"pong", ...}}], "usage":{"total_tokens":19}}   200

The update path goes through clear_cache rather than add_deployment, so I ran it separately: cross-pod delete, then PATCH /model/{id}/update on the other pod. Before it 500s with Model update was saved to the database ... Previously served model id(s) ['cc210d5a-...'] while the patched model serves a live completion; after it returns 200 and the same completion

For the direction that must not change, I broke a reload for real by corrupting a row the db still wants (update "LiteLLM_ProxyModelTable" set litellm_params = '[]'::jsonb), which makes _add_deployment skip it while it stays in the reconciled id set, then patched a different model on that pod:

before  500  Previously served model id(s) ['07b73edc-c9b9-4cb1-bcf5-486a39f30f6b']
after   500  Previously served model id(s) ['8e3bf5c0-7616-45a8-9053-1e9e1c45e66b']

So the guard still fails the write when a model the db still wants stops being served, and only the deliberate cross-pod eviction stops counting

scenario before fa56283806 after 2d5754d928
cross-pod delete, then /model/new 500 200
cross-pod delete, then /model/{id}/update 500 200
genuine reload loss of an id the db still wants 500 500

tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py, tests/test_litellm/proxy/test_update_llm_router_resilience.py and tests/test_litellm/proxy/proxy_server/test_proxy_config.py are 211 passed at the PR head

@ryan-crabbe-berri
ryan-crabbe-berri merged commit b4ff05b into litellm_internal_staging Aug 1, 2026
81 checks passed
@ryan-crabbe-berri
ryan-crabbe-berri deleted the litellm_fix_model_write_reload_guard_false_positive branch August 1, 2026 01:10
timothybrush pushed a commit to timothybrush/litellm that referenced this pull request Aug 1, 2026
… of a delete count

_delete_deployment stopped returning a count of evictions in BerriAI#35400 and now returns
the frozenset of ids the db and config still want, so a caller judging its own reload
can tell a deliberate eviction from a deployment that went missing. These two tests in
tests/local_testing were left comparing that frozenset against an int and have been
failing since; the directory is only referenced by .circleci/config.yml, which no
longer reports checks on PRs, so nothing caught them.

The eviction behavior itself is unchanged, so the fix is on the assertions: compare
against the expected id set, and pin the router's surviving ids so a mutation that
evicts the wrong deployment is caught rather than passing a bare length check.
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