Skip to content

fix(router): await Redis increment pipeline so budget sync doesn't overwrite memory with stale spend - #32624

Closed
devin-ai-integration[bot] wants to merge 1 commit into
mainfrom
litellm_fix_router_budget_stale_redis_overwrite
Closed

devin-ai-integration[bot] wants to merge 1 commit into
mainfrom
litellm_fix_router_budget_stale_redis_overwrite

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Relevant issues

Fixes #32614

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)

Screenshots / Proof of Fix

This is an internal Redis-sync race, so the observable symptom (in-memory spend getting overwritten with a stale Redis value) depends on the background pipeline task not having completed before the sync read. That timing is not deterministic through a single curl against a live proxy, so the proof here is a regression test that reproduces the exact ordering from the issue: in-memory spend is 160, Redis is 100 with a pending +60 increment queued, and the pipeline yields to the event loop before it applies. If the flush is fire-and-forget, the sync reads Redis before the pipeline lands and clobbers memory back to 100; if it is awaited, memory stays 160

Before, at 60729f733e (the file restored to its pre-fix state):

=== BEFORE (buggy) budget_limiter.py from HEAD~1=60729f733e ===
FAILED tests/test_litellm/router_strategy/test_budget_limiter.py::test_push_in_memory_increments_to_redis_completes_before_returning
FAILED tests/test_litellm/router_strategy/test_budget_limiter.py::test_sync_does_not_overwrite_memory_with_stale_redis
    assert float(in_memory_spend) == 160.0
E   assert 100.0 == 160.0
2 failed in 0.24s

After, at 0f030430a7:

=== AFTER (fix applied) HEAD=0f030430a7 ===
tests/test_litellm/router_strategy/test_budget_limiter.py ..            [100%]
2 passed in 0.28s

Type

🐛 Bug Fix

Changes

RouterBudgetLimiting._push_in_memory_increments_to_redis() scheduled the Redis increment pipeline with asyncio.create_task(...) and returned right away, so awaiting it only waited for the task to be scheduled, not for Redis to actually be updated. _sync_in_memory_spend_with_redis() then fetched Redis and wrote those values into the in-memory cache before the pipeline had run, overwriting fresh in-memory spend (e.g. 160) with a stale Redis value (e.g. 100). Because DualCache.async_batch_get_cache() trusts in-memory first, that stale value then flowed into later provider, deployment and tag budget checks in async_filter_deployments(). The same inherited flush also runs in _PROXY_VirtualKeyModelMaxBudgetLimiter.async_log_success_event(), so virtual-key and end-user model budgets were affected too

The fix awaits the pipeline before returning, mirroring the safer pattern already used in BaseRoutingStrategy:

queue_to_push = self.redis_increment_operation_queue
self.redis_increment_operation_queue = []

if len(queue_to_push) > 0:
    await self.dual_cache.redis_cache.async_increment_pipeline(
        increment_list=queue_to_push,
    )

The queue is snapshotted and cleared before the await so increments appended by concurrent requests during the flush are not silently dropped; they simply carry over to the next flush instead of being wiped by an unconditional reset

Link to Devin session: https://app.devin.ai/sessions/75159837ecdc45db81285457e5a86fde

RouterBudgetLimiting._push_in_memory_increments_to_redis scheduled the
increment pipeline with asyncio.create_task and returned immediately, so
_sync_in_memory_spend_with_redis read Redis and overwrote in-memory spend
before the pipeline had run. Await the pipeline so the read reflects the
just-pushed increments, and snapshot the queue before awaiting so
concurrent increments are not dropped.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@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.

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a race condition in RouterBudgetLimiting._push_in_memory_increments_to_redis() where using asyncio.create_task caused the Redis pipeline to be scheduled but not awaited, so _sync_in_memory_spend_with_redis() could read stale Redis values and overwrite fresh in-memory spend before the pipeline landed.

  • Core fix: replaces asyncio.create_task(pipeline) with a direct await, ensuring Redis is fully updated before the sync read runs.
  • Queue snapshot pattern: the operation queue is snapshotted and cleared before the await, so any increments appended by concurrent requests during the flush carry over to the next cycle instead of being lost.
  • Tests: two new focused async tests reproduce the original bug ordering and confirm the fix holds.

Confidence Score: 4/5

The change is a targeted, well-scoped fix validated by two regression tests that reproduce the exact race ordering from the reported issue.

The production change in budget_limiter.py is straightforward and correct. The only finding is dead test setup in the new test file — asyncio.create_task is patched in the fixture but never called by the fixed code.

The budget_limiter fixture in tests/test_litellm/router_strategy/test_budget_limiter.py retains a now-unnecessary monkeypatch for asyncio.create_task.

Important Files Changed

Filename Overview
litellm/router_strategy/budget_limiter.py Replaces fire-and-forget asyncio.create_task with a direct await in _push_in_memory_increments_to_redis, and snapshots the queue before clearing it so concurrent appends carry over to the next flush rather than being dropped.
tests/test_litellm/router_strategy/test_budget_limiter.py New regression-test file with two focused async tests validating the fix; the budget_limiter fixture monkeypatches asyncio.create_task even though the fixed code no longer calls it, leaving dead setup that could confuse future readers.

Reviews (1): Last reviewed commit: "fix(router): await Redis increment pipel..." | Re-trigger Greptile

Comment on lines +34 to +39
@pytest.fixture
def budget_limiter(monkeypatch):
monkeypatch.setattr(asyncio, "create_task", lambda coro: coro.close())
limiter = RouterBudgetLimiting(dual_cache=DualCache(), provider_budget_config={})
limiter.provider_budget_config = {"openai": BudgetConfig(time_period="1d", budget_limit=100)}
return limiter

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 The monkeypatch.setattr(asyncio, "create_task", ...) line is dead code now that the fix removes the asyncio.create_task call from _push_in_memory_increments_to_redis. It no longer affects the tests and may confuse future readers who wonder why the patch is needed.

Suggested change
@pytest.fixture
def budget_limiter(monkeypatch):
monkeypatch.setattr(asyncio, "create_task", lambda coro: coro.close())
limiter = RouterBudgetLimiting(dual_cache=DualCache(), provider_budget_config={})
limiter.provider_budget_config = {"openai": BudgetConfig(time_period="1d", budget_limit=100)}
return limiter
@pytest.fixture
def budget_limiter():
limiter = RouterBudgetLimiting(dual_cache=DualCache(), provider_budget_config={})
limiter.provider_budget_config = {"openai": BudgetConfig(time_period="1d", budget_limit=100)}
return limiter

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@codecov

codecov Bot commented Jul 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_router_budget_stale_redis_overwrite (0f03043) with litellm_internal_staging (60729f7)

Open in CodSpeed

@yuneng-berri
yuneng-berri deleted the branch main September 13, 2026 04:29
@yuneng-berri yuneng-berri reopened this Sep 13, 2026
@devin-ai-integration
devin-ai-integration Bot changed the base branch from litellm_internal_staging to main September 23, 2026 15:47
@devin-ai-integration
devin-ai-integration Bot requested a review from a team September 23, 2026 15:47
@github-actions

Copy link
Copy Markdown
Contributor

#32614 was fixed by #43125 on main, so this pull request is closed. Reopen it if something was missed.

@github-actions github-actions Bot closed this Sep 25, 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.

[Bug]: Router budget sync can overwrite memory cache with stale Redis spend

2 participants