Skip to content

fix(proxy): read through to the DB on registry misses so just-created models, guardrails, and agents resolve on sibling replicas - #36263

Merged
mateo-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_replica_registry_read_through
Aug 19, 2026
Merged

mateo-berri merged 8 commits into
litellm_internal_stagingfrom
litellm_replica_registry_read_through

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Objects created on one replica 400/404 on sibling replicas for seconds
  • Hits models, guardrails, A2A agents, and access group creation
  • Access group writes could falsely 500 during concurrent reloads
  • A failed refresh could silently drop a model a replica was serving

How it solves it:

  • On a registry miss, the replica re-reads the object from the DB
  • It loads the object into its in-memory registry and retries once
  • A 2s negative cache and a 20-per-5s resync budget shield the DB
  • Access group verdicts read the reload's own atomic snapshot
  • A failed model refresh restores the previously served deployment
  • Resyncs honor supported_db_objects and take the reload's reconcile locks
  • An unknown A2A agent miss no longer consumes the model resync budget

User Flow

Before: an admin running two gateway replicas behind one URL creates an object, and the very next call that uses it fails whenever the load balancer picks the other replica; access group writes racing a reload can also return a false 500 and knock a serving model off a replica

  1. They send POST https://litellm-domain/model/new with the admin key and {"model_name": "team-gpt", "litellm_params": {"model": "openai/gpt-4o-mini", ...}} and get 200 with the new deployment id
  2. They immediately send POST https://litellm-domain/v1/chat/completions with {"model": "team-gpt", ...} and, when the other replica answers, get 400 "Invalid model name passed in model=team-gpt. Call /v1/models to view available models for your key." (/v1/responses and /v1/messages fail the same way)
  3. They send POST https://litellm-domain/access_group/new with {"access_group": "prod-models", "model_names": ["team-gpt"]} and get 400 {"error": "Model(s) not found: team-gpt"}
  4. They send POST https://litellm-domain/guardrails registering pii-filter and get 200 with a guardrail id
  5. They immediately send POST https://litellm-domain/guardrails/apply_guardrail with {"guardrail_name": "pii-filter", "text": "..."} and get 404 "Guardrail 'pii-filter' not found. Please ensure the guardrail is configured in your LiteLLM proxy."
  6. They send POST https://litellm-domain/v1/agents registering an agent and get 200 with an agent_id like 6d27a7e1-0aa6-...
  7. They immediately send POST https://litellm-domain/a2a/{agent_id} with JSON-RPC message/send and get {"error": {"code": -32000, "message": "Agent '6d27a7e1-...' not found"}}
  8. Later, while routine model updates land in parallel, they send POST https://litellm-domain/access_group/new naming a model the answering replica already serves and occasionally get 500 {"error": "Access group 'prod-models' was saved to the database, but model id(s) [...] that this pod was serving are no longer live after the reload it triggered. ..."} even though the write saved, and the named model can then stop serving on that replica (chat calls on it 400) until the next background reload
  9. Retrying each failed call a few seconds later succeeds once the replicas catch up, so their provisioning scripts need sleeps and still flake

After: the same admin runs the same sequence and every immediate follow-up call succeeds no matter which replica answers, and access group writes racing a reload stay 200 with no serving gap

  1. They send POST https://litellm-domain/model/new with the admin key and {"model_name": "team-gpt", "litellm_params": {"model": "openai/gpt-4o-mini", ...}} and get 200 with the new deployment id
  2. They immediately send POST https://litellm-domain/v1/chat/completions with {"model": "team-gpt", ...} and get 200 with a real completion from the new deployment, on whichever replica the call lands (/v1/responses and /v1/messages likewise)
  3. They send POST https://litellm-domain/access_group/new with {"access_group": "prod-models", "model_names": ["team-gpt"]} and get 200 {"access_group": "prod-models", ..., "models_updated": 1}
  4. They send POST https://litellm-domain/guardrails registering pii-filter and get 200 with a guardrail id
  5. They immediately send POST https://litellm-domain/guardrails/apply_guardrail and get 200 {"response_text": "..."}
  6. They send POST https://litellm-domain/v1/agents registering an agent and get 200 with an agent_id
  7. They immediately send POST https://litellm-domain/a2a/{agent_id} with JSON-RPC message/send and get a JSON-RPC result carrying the agent's real reply
  8. The same access group writes racing parallel model updates return 200 {..., "models_updated": 1} every time, and the models they name keep serving throughout
  9. No sleeps or retry loops needed; provision-then-use flows pass deterministically

Relevant issues

Linear ticket

Resolves LIT-5791

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

Setup: two proxies booted from the same checkout sharing one fresh Postgres (store_model_in_db: true, no Redis), replica A and replica B on separate high ports. Every "create on A" is immediately followed by a "use on B" curl, which is exactly the cross-replica race from the 2-replica e2e suite. All LLM calls hit the real OpenAI API with gpt-4o-mini and cost real $

A=http://localhost:<portA>; B=http://localhost:<portB>
K="Authorization: Bearer $MASTER_KEY"; J="Content-Type: application/json"

Before (6d32d40)

model created on A, immediate /v1/chat/completions on B

  1. curl -X POST $A/model/new -H "$K" -H "$J" -d '{"model_name": "lit5791-miss-2272", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "os.environ/OPENAI_API_KEY"}}' returns 200 with "model_id": "a3d0f437-8e53-4bee-936b-0511f3c4732c"
  2. curl -X POST $B/chat/completions -H "$K" -H "$J" -d '{"model": "lit5791-miss-2272", "messages": [{"role": "user", "content": "say hi"}]}' returns HTTP 400: /chat/completions: Invalid model name passed in model=lit5791-miss-2272. Call /v1/models to view available models for your key.

model created on A, immediate /v1/responses on B

  1. curl -X POST $A/model/new ... (fresh name lit5791-before-responses-971) returns 200
  2. curl -X POST $B/v1/responses -H "$K" -H "$J" -d '{"model": "lit5791-before-responses-971", "input": "Reply with exactly: read-through works"}' returns HTTP 400: /responses: Invalid model name passed in model=lit5791-before-responses-971. ...

model created on A, immediate /v1/messages on B

  1. curl -X POST $A/model/new ... (fresh name lit5791-before-messages-591) returns 200
  2. curl -X POST $B/v1/messages -H "$K" -H "$J" -d '{"model": "lit5791-before-messages-591", "max_tokens": 32, "messages": [{"role": "user", "content": "Reply with exactly: read-through works"}]}' returns HTTP 400: anthropic_messages: Invalid model name passed in model=lit5791-before-messages-591. ...

guardrail created on A, applied on B

  1. curl -X POST $A/guardrails -H "$K" -H "$J" -d '{"guardrail": {"guardrail_name": "lit5791-guard-7957", "litellm_params": {"guardrail": "litellm_content_filter", "mode": "pre_call", "default_on": false, "blocked_words": [{"keyword": "zebra", "action": "BLOCK"}]}}}' returns 200
  2. curl -X POST $B/guardrails/apply_guardrail -H "$K" -H "$J" -d '{"guardrail_name": "lit5791-guard-7957", "text": "hello world"}' returns HTTP 404: Guardrail 'lit5791-guard-7957' not found. Please ensure the guardrail is configured in your LiteLLM proxy.

A2A agent created on A, message/send on B

  1. curl -X POST $A/v1/agents -H "$K" -H "$J" -d '{"agent_name": "lit5791-agent-13316", "agent_card_params": {...}, "litellm_params": {"custom_llm_provider": "openai", "model": "gpt-4o-mini"}}' returns 200 with "agent_id": "ff8a3a61-4c0c-4acd-b904-4928768e5dd1"
  2. curl -X POST $B/a2a/lit5791-agent-13316 -H "$K" -H "$J" -d '{"jsonrpc": "2.0", "id": "1", "method": "message/send", "params": {"message": {"role": "user", "parts": [{"kind": "text", "text": "Reply with exactly: a2a works"}], "messageId": "m1"}}}' returns HTTP 404: {"jsonrpc":"2.0","id":"1","error":{"code":-32000,"message":"Agent 'lit5791-agent-13316' not found"}}

model created on A, access group naming it on B

  1. curl -X POST $A/model/new ... (fresh name lit5791-xag-14571) returns 200
  2. curl -X POST $B/access_group/new -H "$K" -H "$J" -d '{"access_group": "lit5791-xg", "model_names": ["lit5791-xag-14571"]}' returns HTTP 400: {"detail":{"error":"Model(s) not found: lit5791-xag-14571"}}

unknown model on B keeps failing with 400

  1. curl -X POST $B/chat/completions -H "$K" -H "$J" -d '{"model": "lit5791-never-created", "messages": [{"role": "user", "content": "hi"}]}' returns HTTP 400: /chat/completions: Invalid model name passed in model=lit5791-never-created. ...

concurrent access-group writes during reload churn

  1. Seed two models on A, then run 150 iterations of two concurrent curl -X POST $A/access_group/new ... writes, each naming its own model
  2. All 300 writes return 200 locally: the false 500 from the 2-replica e2e run (signature Access group '...' was saved to the database, but model id(s) [...] that this pod was serving are no longer live after the reload it triggered) needs multi-pod reload timing and did not trigger on a single box

After (afeed48, re-run green at 790d645 after the staging merge and at f8a23aa after the guardrail-review fixes)

model created on A, immediate /v1/chat/completions on B

  1. curl -X POST $A/model/new ... (fresh name lit5791-after-miss-26853) returns 200
  2. curl -X POST $B/chat/completions -H "$K" -H "$J" -d '{"model": "lit5791-after-miss-26853", "messages": [{"role": "user", "content": "Reply with exactly: read-through works"}]}' returns HTTP 200: "choices":[{"finish_reason":"stop","index":0,"message":{"content":"read-through works","role":"assistant",...}}]

model created on A, immediate /v1/responses on B

  1. curl -X POST $A/model/new ... (fresh name lit5791-after-responses-*) returns 200
  2. curl -X POST $B/v1/responses ... (same payload shape as Before) returns HTTP 200 with a real response object: {"id":"resp_b6eh-WdsfbALI5MzCTklqw...

model created on A, immediate /v1/messages on B

  1. curl -X POST $A/model/new ... (fresh name lit5791-after-messages-6846) returns 200
  2. curl -X POST $B/v1/messages ... (same payload shape as Before) returns HTTP 200: {"id":"resp_...","type":"message","role":"assistant","model":"lit5791-after-messages-6846",...}

guardrail created on A, applied on B

  1. curl -X POST $A/guardrails ... (same payload shape as Before, fresh name) returns 200
  2. curl -X POST $B/guardrails/apply_guardrail ... returns HTTP 200: {"response_text":"hello world"}

A2A agent created on A, message/send on B

  1. curl -X POST $A/v1/agents ... (same payload shape as Before, fresh name) returns 200
  2. curl -X POST $B/a2a/<agent_name> ... returns HTTP 200: {"id":"1","jsonrpc":"2.0","result":{"kind":"message","role":"agent","parts":[{"kind":"text","text":"a2a works"}],...}}

model created on A, access group naming it on B

  1. curl -X POST $A/model/new ... (fresh name lit5791-after-xag-815) returns 200
  2. curl -X POST $B/access_group/new ... returns HTTP 200: {"access_group":"lit5791-after-xg-32490","model_names":["lit5791-after-xag-815"],"model_ids":null,"models_updated":1}

unknown model on B keeps failing with 400

  1. curl -X POST $B/chat/completions ... with lit5791-never-created returns HTTP 400 with the identical error text, still immediate (the 2s negative cache absorbs repeats)

concurrent access-group writes during reload churn

  1. Seed two models on A, then run 100 iterations of two concurrent curl -X POST $A/access_group/new ... writes, each naming its own model
  2. All 200 writes return 200 (non-200 access-group writes: 0/200), and both replica logs contain zero Error upserting deployment lines, so no deployment was dropped by the churn

cross-replica risk probes at the same tip

  1. curl -X PUT $B/access_group/lit5791-risk-ag-3229/update ... naming a model just created on A returns HTTP 200: {"access_group":"lit5791-risk-ag-3229","model_names":["lit5791-risk-new-2527"],...,"models_updated":1}
  2. curl $B/a2a/<agent_name>/.well-known/agent-card.json for an agent created on A returns HTTP 200 with the full agent card
  3. curl -X DELETE $B/access_group/lit5791-risk-ag-3229/delete returns HTTP 200: {"models_updated":1,"message":"Access group 'lit5791-risk-ag-3229' deleted successfully"}
  4. curl -X POST $B/rerank ... with an unknown model returns HTTP 400 with the clean invalid-model error, so the retry wrapper regressed nothing on other route types
  5. curl -X POST $B/a2a/lit5791-agent-nobody-created ... returns HTTP 404 JSON-RPC -32000 Agent 'lit5791-agent-nobody-created' not found, and the miss does not consume the model resync budget

Type

🐛 Bug Fix

Caveats

  • The access group false 500 reproduces only under multi-pod CI timing
  • Locally it is covered by unit tests plus the 200-write loop above
  • Resyncs are budgeted at 20 per 5s per replica; excess misses fail as today

Changes

New litellm/proxy/common_utils/registry_read_through.py holds one small RegistryReadThrough primitive: an asyncio-locked "re-read from the DB, load into the local registry, answer whether the key exists now" step with a 2 second negative-result TTL, plus a global budget of 20 resyncs per 5 second window that fails closed (the miss stands, nothing is negative-cached) so a flood of distinct unknown names can never turn into a DB hot loop. Three singletons wrap it for the three registries

Each resync fetches only the missing object. The model resync selects just the rows matching the missing name (by model_name, then model_id) and upserts them through the additive deployment loader; it never runs the delete-reconciling full router rebuild with a partial list, which would evict healthy deployments. The agent resync does a unique lookup by agent_id, then agent_name, including the object permission, and registers the row as-is. The guardrail resync goes through the same DB loader the periodic config reload uses

Four miss sites consult it before giving up. The LLM request router retries routing once after a model read-through (covers /v1/chat/completions, /v1/responses, /v1/messages, and every other route the shared router serves). The A2A agent card and message/send lookups go through the agent read-through, as does A2A model routing. POST /guardrails/apply_guardrail re-checks after a guardrail read-through. Access group create and update re-validate their model names after a model read-through and only then 400

Two hardenings close the reload race the 2-replica e2e suite hit. The router reload now returns an atomic snapshot of which model ids were still desired and live when it finished, and the access group endpoints judge their "saved but no longer serving" 500 against that snapshot instead of re-reading router state that a concurrent reload may have mutated. And Router.upsert_deployment restores the previously served deployment when replacing it fails under ignore_invalid_deployments, so a bad refresh degrades to the old config instead of silently dropping the model

A follow-up commit (afeed48) closes three gaps review found in the first cut. The model resync now mutates the router under MODEL_RECONCILE_LOCK, the same lock the full reconcile snapshots and reconciles under, so a read-through upsert can no longer interleave with a reload and get evicted by its stale snapshot. A new AGENT_RECONCILE_LOCK serializes the agent resync with _init_agents_in_db's fetch-and-rebuild, with a re-check under the lock so an agent is never registered twice. Every resync first consults should_load_db_object (extracted to module level from ProxyConfig._should_load_db_object, which never used instance state), so a deployment that excludes an object type via supported_db_objects is honored by the read-through exactly as by the reload. And the A2A raise sites mark ProxyModelNotFoundError as not retryable with a model read-through, so an unknown agent no longer burns a model resync on the way out. Each fix carries a regression test: lock-held assertions on both resyncs and the reload, gating skips per object type, a duplicate-registration guard, and a budget-preservation test on the A2A miss path

Unit tests cover the primitive (negative TTL expiry, no negative caching on resync exceptions or budget exhaustion, concurrent misses coalescing into one resync), one regression test per miss site driving the real recovery path against fake DB rows, and the upsert rollback

Two review findings at the merged tip are fixed in f8a23aa. The guardrail read-through now filters to status: "active" in the DB query itself, mirroring the reload's get_all_guardrails_from_db, so a team-submitted pending_review guardrail can no longer be activated by requesting it on a sibling pod. And a new GUARDRAIL_RECONCILE_LOCK serializes the guardrail resync with _init_guardrails_in_db's snapshot-through-reconcile, closing the same eviction race models and agents were already locked against. Both carry regression tests: a status-filter assertion on the resync query, and lock-held assertions on the resync and the reload

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

  • f8a23aa passes /live-pr-risk (re-run at the tip; includes a live probe that a registered pending_review guardrail stays 404 on both pods)


Note

Medium Risk
Touches core proxy routing, model router reconciliation, and multi-replica consistency; changes are guarded by locks, budgets, and extensive tests but affect hot request paths.

Overview
Fixes stale in-memory registries on sibling proxy replicas: after a model, guardrail, or agent is created on one pod, requests on another no longer 400/404 until the periodic reload.

Adds registry_read_through: on a miss it loads the row from Postgres into the local registry (with a 2s negative cache and a 20-per-5s resync cap), then retries once. Wired into LLM routing, A2A lookups, apply_guardrail, and access-group model validation. Resyncs run under MODEL_RECONCILE_LOCK / AGENT_RECONCILE_LOCK and honor supported_db_objects. Unknown A2A agent errors skip model read-through.

Access-group reload checks use ReconcileOutcome from clear_cache instead of a fresh router read, avoiding false 500s when concurrent reloads race. Router.upsert_deployment restores the previous deployment when an update fails under ignore_invalid_deployments.

Reviewed by Cursor Bugbot for commit 790d645. Bugbot is set up for automated code reviews on this repo. Configure here.

… models, guardrails, and agents resolve on sibling replicas
@greptile-apps

greptile-apps Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds bounded database read-through recovery for model, guardrail, and agent registry misses across replicas while hardening concurrent registry reconciliation.

  • Retries model, guardrail, agent, and access-group lookups after targeted database hydration.
  • Serializes targeted hydration with full model, guardrail, and agent reconciliation.
  • Uses atomic reload outcomes for access-group serving checks and restores previous deployments after failed upserts.
  • Adds regression coverage for caching, budgets, reconciliation locks, supported object gating, cross-replica recovery, and rollback behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/proxy/common_utils/registry_read_through.py Introduces bounded, negative-cached, per-registry database read-through with reconciliation locking and supported-object gating.
litellm/proxy/route_llm_request.py Retries model routing once after a recoverable local-registry miss while exempting non-model A2A failures.
litellm/proxy/proxy_server.py Extracts database-object gating and serializes guardrail and agent snapshot reconciliation.
litellm/proxy/management_endpoints/model_access_group_management_endpoints.py Hydrates missing models before validation and evaluates reload degradation against the reload’s atomic outcome.
litellm/router.py Restores a previously serving deployment when a replacement fails under ignored-invalid-deployment handling.
litellm/proxy/agent_endpoints/a2a_routing.py Adds agent registry read-through and prevents unresolved agent failures from consuming the model recovery budget.
litellm/proxy/guardrails/guardrail_endpoints.py Resolves guardrails through the bounded read-through path before returning not found.
tests/test_litellm/proxy/common_utils/test_registry_read_through.py Covers negative caching, budget behavior, registry recovery, object gating, status filtering, and reconciliation locking.

Reviews (8): Last reviewed commit: "fix: gate guardrail read-through to acti..." | Re-trigger Greptile

Comment thread litellm/proxy/common_utils/registry_read_through.py Outdated
Comment thread litellm/proxy/common_utils/registry_read_through.py Outdated
Comment thread litellm/proxy/common_utils/registry_read_through.py Outdated
@veria-ai

veria-ai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

…itellm_replica_registry_read_through

# Conflicts:
#	litellm/proxy/management_endpoints/model_access_group_management_endpoints.py
#	ruff.toml
#	tests/test_litellm/proxy/test_route_llm_request.py
Resync registry misses with single-row DB fetches (guardrail by unique
name, agent by unique id or name, model by name then id) instead of
full-table loads, and bound them with a global budget of 20 resyncs per
5s window per registry that fails closed without negative-caching the
key.

Access group create/update now trust the reconcile outcome snapshot
captured under the reload lock instead of a post-lock router read, so a
concurrent reconcile can no longer surface a false degraded-serving 500.

Router.upsert_deployment restores the previously served deployment when
the replacement add fails under ignore_invalid_deployments, so a bad
update no longer silently drops a healthy deployment from serving.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/common_utils/registry_read_through.py
@codecov

codecov Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

@cursor cursor Bot left a comment •

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.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Read-through skips reconcile lock
    • Both model and agent read-through resyncs now acquire MODEL_RECONCILE_LOCK across the DB fetch and registry write, serializing them against the periodic reload's reset/evict path.
  • ✅ Fixed: A2A misses spend model budget
    • route_request now re-raises ProxyModelNotFoundError immediately for a2a/ models instead of consuming the model registry read-through budget on agent-not-found failures.
  • ✅ Fixed: Read-through ignores object-type gates
    • _db_backed_registries_enabled now takes an object_type and consults proxy_config._should_load_db_object so read-through skips types excluded from supported_db_objects.

Create PR

Or push these changes by commenting:

@cursor push 55aa29f53d
Preview (55aa29f53d)
diff --git a/litellm/proxy/common_utils/registry_read_through.py b/litellm/proxy/common_utils/registry_read_through.py
--- a/litellm/proxy/common_utils/registry_read_through.py
+++ b/litellm/proxy/common_utils/registry_read_through.py
@@ -95,17 +95,20 @@
             return found
 
 
-def _db_backed_registries_enabled() -> bool:
+def _db_backed_registries_enabled(object_type: str) -> bool:
     from litellm.proxy import proxy_server
 
-    return proxy_server.prisma_client is not None and proxy_server.store_model_in_db is True
+    if proxy_server.prisma_client is None or proxy_server.store_model_in_db is not True:
+        return False
+    return proxy_server.proxy_config._should_load_db_object(object_type=object_type)
 
 
 async def _resync_model_deployments(model_name: str) -> bool:
     from litellm.proxy import proxy_server
+    from litellm.proxy.proxy_server import MODEL_RECONCILE_LOCK
     from litellm.repositories.model_repository import ModelRepository
 
-    if not _db_backed_registries_enabled():
+    if not _db_backed_registries_enabled(object_type="models"):
         return False
     prisma_client: Final = proxy_server.prisma_client
     assert prisma_client is not None
@@ -120,8 +123,11 @@
             prisma_client=prisma_client, proxy_logging_obj=proxy_server.proxy_logging_obj
         )
         return proxy_server.llm_router is not None
-    proxy_server.proxy_config._add_deployment(db_models=rows)
-    proxy_server.llm_model_list = proxy_server.llm_router.get_model_list()
+    async with MODEL_RECONCILE_LOCK:
+        if proxy_server.llm_router is None:
+            return False
+        proxy_server.proxy_config._add_deployment(db_models=rows)
+        proxy_server.llm_model_list = proxy_server.llm_router.get_model_list()
     return True
 
 
@@ -132,7 +138,7 @@
         GuardrailRegistry,
     )
 
-    if not _db_backed_registries_enabled():
+    if not _db_backed_registries_enabled(object_type="guardrails"):
         return False
     prisma_client: Final = proxy_server.prisma_client
     assert prisma_client is not None
@@ -151,9 +157,10 @@
         agents_table,
         global_agent_registry,
     )
+    from litellm.proxy.proxy_server import MODEL_RECONCILE_LOCK
     from litellm.types.agents import AgentResponse
 
-    if not _db_backed_registries_enabled():
+    if not _db_backed_registries_enabled(object_type="agents"):
         return False
     if _agent_from_registry(agent_id_or_name) is not None:
         return True
@@ -163,12 +170,15 @@
     id_filter: Final[LiteLLM_AgentsTableWhereUniqueInput] = {"agent_id": agent_id_or_name}
     name_filter: Final[LiteLLM_AgentsTableWhereUniqueInput] = {"agent_name": agent_id_or_name}
     include_permission: Final[LiteLLM_AgentsTableInclude] = {"object_permission": True}
-    row: Final = await table.find_unique(where=id_filter, include=include_permission) or await table.find_unique(
-        where=name_filter, include=include_permission
-    )
-    if row is None:
-        return False
-    global_agent_registry.register_agent(agent_config=AgentResponse.model_validate(row.model_dump()))
+    async with MODEL_RECONCILE_LOCK:
+        if _agent_from_registry(agent_id_or_name) is not None:
+            return True
+        row: Final = await table.find_unique(where=id_filter, include=include_permission) or await table.find_unique(
+            where=name_filter, include=include_permission
+        )
+        if row is None:
+            return False
+        global_agent_registry.register_agent(agent_config=AgentResponse.model_validate(row.model_dump()))
     return True
 
 

diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py
--- a/litellm/proxy/route_llm_request.py
+++ b/litellm/proxy/route_llm_request.py
@@ -441,6 +441,11 @@
         requested_model: Final = data.get("model", "")
         if not isinstance(requested_model, str) or not requested_model:
             raise
+        # A2A routing already runs its own agent read-through; retrying against the
+        # model registry would burn its budget on `a2a/<name>` keys and can fail-closed
+        # a sibling-created model for the rest of the window.
+        if _is_a2a_agent_model(requested_model):
+            raise
         from litellm.proxy import proxy_server
         from litellm.proxy.common_utils.registry_read_through import (
             model_registry_read_through,

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/common_utils/registry_read_through.py
Comment thread litellm/proxy/route_llm_request.py
Comment thread litellm/proxy/common_utils/registry_read_through.py Outdated
@codspeed

codspeed Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_replica_registry_read_through (f8a23aa) with litellm_internal_staging (559310f)1

Open in CodSpeed

Footnotes

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

The model resync now mutates the router under MODEL_RECONCILE_LOCK, and the
agent resync shares the new AGENT_RECONCILE_LOCK with the periodic agent
reload, so a reconcile built from a pre-write DB snapshot can no longer evict
or duplicate what a read-through just registered. Every resync checks
should_load_db_object for its object type, keeping read-through consistent
with what the replica is configured to load, and the a2a raise sites tag
ProxyModelNotFoundError as non-retryable so an agent miss no longer burns the
model resync budget.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit afeed48. Configure here.

…/litellm into litellm_replica_registry_read_through

# Conflicts:
#	tests/test_litellm/proxy/test_proxy_server.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/common_utils/registry_read_through.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment •

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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Guardrail hydrate races reload eviction
    • Added a GUARDRAIL_RECONCILE_LOCK held across both the periodic reload's snapshot-and-reconcile and the read-through's DB fetch-plus-sync, matching the model and agent reconcile-lock pattern so a just-hydrated sibling-replica guardrail can no longer be evicted.

Create PR

Or push these changes by commenting:

@cursor push fc18a726f3
Preview (fc18a726f3)
diff --git a/litellm/proxy/common_utils/registry_read_through.py b/litellm/proxy/common_utils/registry_read_through.py
--- a/litellm/proxy/common_utils/registry_read_through.py
+++ b/litellm/proxy/common_utils/registry_read_through.py
@@ -132,21 +132,27 @@
 async def _resync_guardrails(guardrail_name: str) -> bool:
     from litellm.proxy import proxy_server
     from litellm.proxy.guardrails.guardrail_registry import (
+        GUARDRAIL_RECONCILE_LOCK,
         IN_MEMORY_GUARDRAIL_HANDLER,
         GuardrailRegistry,
     )
 
     if not _db_backed_registries_enabled("guardrails"):
         return False
+    if _initialized_guardrail(guardrail_name) is not None:
+        return True
     prisma_client: Final = proxy_server.prisma_client
     assert prisma_client is not None
-    row: Final = await GuardrailRegistry().get_guardrail_by_name_from_db(
-        guardrail_name=guardrail_name, prisma_client=prisma_client
-    )
-    if row is None:
-        return False
-    IN_MEMORY_GUARDRAIL_HANDLER.sync_guardrail_from_db(guardrail=row)
-    return _initialized_guardrail(guardrail_name) is not None
+    async with GUARDRAIL_RECONCILE_LOCK:
+        if _initialized_guardrail(guardrail_name) is not None:
+            return True
+        row: Final = await GuardrailRegistry().get_guardrail_by_name_from_db(
+            guardrail_name=guardrail_name, prisma_client=prisma_client
+        )
+        if row is None:
+            return False
+        IN_MEMORY_GUARDRAIL_HANDLER.sync_guardrail_from_db(guardrail=row)
+        return _initialized_guardrail(guardrail_name) is not None
 
 
 async def _resync_agents(agent_id_or_name: str) -> bool:

diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py
--- a/litellm/proxy/guardrails/guardrail_registry.py
+++ b/litellm/proxy/guardrails/guardrail_registry.py
@@ -1,5 +1,6 @@
 # litellm/proxy/guardrails/guardrail_registry.py
 
+import asyncio
 import importlib
 import os
 from collections.abc import Callable, Iterator, Mapping
@@ -813,4 +814,5 @@
 # In Memory Guardrail Handler for LiteLLM Proxy
 ########################################################
 IN_MEMORY_GUARDRAIL_HANDLER: Final = InMemoryGuardrailHandler()
+GUARDRAIL_RECONCILE_LOCK: Final = asyncio.Lock()
 ########################################################

diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -7090,38 +7090,40 @@
 
     async def _init_guardrails_in_db(self, prisma_client: PrismaClient):
         from litellm.proxy.guardrails.guardrail_registry import (
+            GUARDRAIL_RECONCILE_LOCK,
             IN_MEMORY_GUARDRAIL_HANDLER,
             Guardrail,
             GuardrailRegistry,
         )
 
         try:
-            guardrails_in_db: Final[list[Guardrail]] = await GuardrailRegistry.get_all_guardrails_from_db(
-                prisma_client=prisma_client
-            )
-            verbose_proxy_logger.debug("guardrails from the DB %s", str(guardrails_in_db))
-            db_guardrail_ids: Final[set] = set()
-            for guardrail in guardrails_in_db:
-                guardrail_id = guardrail.get("guardrail_id")
-                if guardrail_id:
-                    db_guardrail_ids.add(guardrail_id)
-                try:
-                    IN_MEMORY_GUARDRAIL_HANDLER.sync_guardrail_from_db(
-                        guardrail=cast(Guardrail, guardrail),
-                    )
-                except Exception as e:  # noqa: BLE001  # one unloadable row must not stop the remaining guardrails
-                    verbose_proxy_logger.error(
-                        "litellm.proxy.proxy_server.py::ProxyConfig:_init_guardrails_in_db - "
-                        "skipping guardrail '%s' (ID: %s): %s: %s",
-                        guardrail.get("guardrail_name"),
-                        guardrail_id,
-                        type(e).__name__,
-                        e,
-                    )
+            async with GUARDRAIL_RECONCILE_LOCK:
+                guardrails_in_db: Final[list[Guardrail]] = await GuardrailRegistry.get_all_guardrails_from_db(
+                    prisma_client=prisma_client
+                )
+                verbose_proxy_logger.debug("guardrails from the DB %s", str(guardrails_in_db))
+                db_guardrail_ids: Final[set] = set()
+                for guardrail in guardrails_in_db:
+                    guardrail_id = guardrail.get("guardrail_id")
+                    if guardrail_id:
+                        db_guardrail_ids.add(guardrail_id)
+                    try:
+                        IN_MEMORY_GUARDRAIL_HANDLER.sync_guardrail_from_db(
+                            guardrail=cast(Guardrail, guardrail),
+                        )
+                    except Exception as e:  # noqa: BLE001  # one unloadable row must not stop the remaining guardrails
+                        verbose_proxy_logger.error(
+                            "litellm.proxy.proxy_server.py::ProxyConfig:_init_guardrails_in_db - "
+                            "skipping guardrail '%s' (ID: %s): %s: %s",
+                            guardrail.get("guardrail_name"),
+                            guardrail_id,
+                            type(e).__name__,
+                            e,
+                        )
 
-            # Drop in-memory DB-backed entries whose row was deleted on another
-            # pod. Config-loaded entries are never touched.
-            IN_MEMORY_GUARDRAIL_HANDLER.reconcile_db_guardrails(db_guardrail_ids=db_guardrail_ids)
+                # Drop in-memory DB-backed entries whose row was deleted on another
+                # pod. Config-loaded entries are never touched.
+                IN_MEMORY_GUARDRAIL_HANDLER.reconcile_db_guardrails(db_guardrail_ids=db_guardrail_ids)
         except Exception as e:
             verbose_proxy_logger.exception("litellm.proxy.proxy_server.py::ProxyConfig:_init_guardrails_in_db - %s", e)
 

diff --git a/tests/test_litellm/proxy/common_utils/test_registry_read_through.py b/tests/test_litellm/proxy/common_utils/test_registry_read_through.py
--- a/tests/test_litellm/proxy/common_utils/test_registry_read_through.py
+++ b/tests/test_litellm/proxy/common_utils/test_registry_read_through.py
@@ -422,3 +422,37 @@
 
     assert await resync_task is True
     assert len(clean_agent_registry.agent_list) == 1
+
+
+@pytest.mark.asyncio
+async def test_resync_guardrails_waits_for_guardrail_reload_and_skips_duplicate_sync(monkeypatch):
+    from unittest.mock import AsyncMock, MagicMock
+
+    import litellm.proxy.proxy_server as proxy_server
+    from litellm.proxy.common_utils.registry_read_through import _resync_guardrails
+    from litellm.proxy.guardrails.guardrail_registry import (
+        GUARDRAIL_RECONCILE_LOCK,
+        IN_MEMORY_GUARDRAIL_HANDLER,
+    )
+
+    guardrail_id: Final = "reload-race-guardrail-id"
+    guardrail_name: Final = "reload-race-guardrail"
+    prisma_client: Final = MagicMock()
+    prisma_client.db.litellm_guardrailstable.find_unique = AsyncMock(
+        side_effect=AssertionError("db hit while the guardrail reload held the reconcile lock")
+    )
+    monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
+    monkeypatch.setattr(proxy_server, "store_model_in_db", True)
+
+    try:
+        async with GUARDRAIL_RECONCILE_LOCK:
+            resync_task: Final = asyncio.ensure_future(_resync_guardrails(guardrail_name))
+            await asyncio.sleep(0.05)
+            assert not resync_task.done()
+            IN_MEMORY_GUARDRAIL_HANDLER.sync_guardrail_from_db(
+                guardrail=dict(FakeGuardrailRow(guardrail_id, guardrail_name))
+            )
+
+        assert await resync_task is True
+    finally:
+        IN_MEMORY_GUARDRAIL_HANDLER.delete_in_memory_guardrail(guardrail_id)

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/common_utils/registry_read_through.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f8a23aa. Configure here.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/common_utils/registry_read_through.py
@mateo-berri
mateo-berri enabled auto-merge August 19, 2026 09:38
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@tin-berri tin-berri left a comment

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.

LGTM — this is a well-tested fix for a real multi-replica consistency problem, and I verified the core mechanisms independently:

  • Read-through + budget/negative-cache: RegistryReadThrough in litellm/proxy/common_utils/registry_read_through.py is instantiated separately per object type (model/guardrail/agent), giving genuinely independent 20-per-5s budgets — confirmed by the A2A-miss-doesn't-starve-model-resync test. Budget exhaustion correctly skips negative-caching a throttled key rather than poisoning it as a false miss.
  • Access-group snapshot: verdicts now read reload_outcome.still_desired/live_after captured atomically under the reload lock, rather than a fresh post-lock router read — directly demonstrated by a test simulating a concurrently-wiped router.
  • Reconcile locks: for guardrails/agents, both the full periodic reload and the miss-triggered resync take the same lock, verified with an interleaved-asyncio race test. For models, MODEL_RECONCILE_LOCK isn't defined in this diff, but I confirmed it's pre-existing shared infrastructure in proxy_server.py already used to serialize model delete/reconcile — this PR's resync path correctly reuses it rather than introducing a parallel lock.
  • Fail-safe refresh: _restore_deployment_after_failed_upsert only fires on an upsert failure for an existing model_id; legitimate deletions go through a separate path, so a real delete won't get incorrectly kept alive.
  • Scope: the large route_llm_request.py diff is a mechanical Literal→RouteType extraction to support the new retry wrapper's type signature, not unrelated drift.

One acknowledged, bounded tradeoff worth naming rather than a bug: the 2s negative cache means a miss checked just before a real create lands on another replica can still defeat the read-through fix for up to that window — not airtight for the tightest possible race, but a reasonable DB-protection tradeoff.

@mateo-berri
mateo-berri merged commit 133e72c into litellm_internal_staging Aug 19, 2026
73 checks passed
@mateo-berri
mateo-berri deleted the litellm_replica_registry_read_through branch August 19, 2026 17:46
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