Skip to content

fix(proxy): make spend counter reseed idempotent across pods via SET NX - #28590

Closed
silencedoctor wants to merge 1 commit into
BerriAI:litellm_internal_stagingfrom
silencedoctor:fix/spend-counter-reseed-idempotent
Closed

fix(proxy): make spend counter reseed idempotent across pods via SET NX#28590
silencedoctor wants to merge 1 commit into
BerriAI:litellm_internal_stagingfrom
silencedoctor:fix/spend-counter-reseed-idempotent

Conversation

@silencedoctor

Copy link
Copy Markdown
Contributor

Reopens #28248 (auto-closed when base branch `shin_agent_oss_staging_05_19_2026` was deleted after that staging branch merged into main). Same diff, rebased onto stable `litellm_oss_staging`.

Summary

Multi-pod proxy deployments could over-inflate spend counters: when a cold Redis counter expired, N pods racing to reseed all called `INCRBYFLOAT +db_spend`, producing `N × db_spend`. The fix uses Redis `SET NX` so only the first pod seeds the cached value; everyone else falls back to the normal increment path.

  • `litellm/proxy/db/spend_counter_reseed.py`: switch reseed write to `async_set_cache(nx=True)`, with a narrow fallback for the rare window where `SET NX` returns False but the subsequent GET returns None (winner's key expired in between).
  • Existing reseed test stubs extended to model the new path. No assertions weakened.

Test plan

  • `tests/test_litellm/proxy/test_proxy_server.py` — extended reseed tests pass
  • `uv run pytest tests/test_litellm/proxy/test_proxy_server.py -k reseed` — green locally

SpendCounterReseed.coalesced() re-seeded the Redis spend counter from
DB using async_increment() (Redis INCRBYFLOAT), which is additive, not
idempotent. The per-process asyncio.Lock added in BerriAI#26459 collapses
duplicate reseeds within a single pod, but provides no cross-pod
coordination. With N proxy pods racing the same cold or expired
counter, Redis ends up at approximately N * db_spend and subsequent
get_current_spend() calls in user_api_key_auth -> common_checks raise
BudgetExceededError on entities well under their configured limit.

Switch the reseed write to async_set_cache(..., nx=True) and, on a
lost race, read back the winner's value. Mirrors the existing pattern
already used by coalesced_window() in the same file. Single-pod
behavior is unchanged (INCRBYFLOAT from absent is equivalent to SET);
only the broken multi-pod path is fixed.

Tests:
- Adds test_coalesced_reseed_idempotent_under_concurrent_multi_pod_reseed
  simulating N pods with independent locks and a shared in-memory Redis
  to assert the counter converges to db_spend, not N * db_spend.
- Adds test_coalesced_reseed_toctou_fallback_uses_increment_when_winner_key_vanishes
  covering the narrow window where SET NX returns False but the
  follow-up GET returns None (winner's key expired in between), so the
  fallback to async_increment still warms the cache.
- Existing reseed test stubs are extended with the
  async_set_cache(nx=True) side-effect so they continue to model the
  new idempotent reseed path correctly. No assertion semantics weakened.
@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.71429% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/db/spend_counter_reseed.py 85.71% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a multi-pod spend counter inflation bug where N pods racing to reseed a cold Redis counter each called INCRBYFLOAT +db_spend, producing N × db_spend. The fix replaces that write with a Redis SET NX so only the first writer seeds the value; all other pods fall back to reading the winner's value.

  • spend_counter_reseed.py: coalesced() now calls async_set_cache(nx=True) for the initial seed; a narrow TOCTOU fallback to INCRBYFLOAT handles the race where the winner's key expires between SET NX and GET, mirroring the already-existing pattern in coalesced_window().
  • test_proxy_server.py: Existing reseed test stubs updated to model the new SET NX path (no assertions weakened), plus two new tests — test_coalesced_reseed_idempotent_under_concurrent_multi_pod_reseed (concurrency regression) and test_coalesced_reseed_toctou_fallback_uses_increment_when_winner_key_vanishes (TOCTOU edge case).

Confidence Score: 4/5

Safe to merge — the core fix correctly prevents multi-pod spend inflation and the test coverage is thorough, with one minor residual edge case to be aware of.

The SET NX approach is sound and mirrors the already-established pattern in coalesced_window(). The only remaining concern is the TOCTOU fallback: if the winner's key expires between a loser's failed NX and the loser's GET, multiple losers still fall through to INCRBYFLOAT, which can re-inflate the counter in a very narrow window. This is documented and astronomically unlikely in practice, but it is a remaining imperfection in the fix rather than a complete solution.

The TOCTOU fallback block in litellm/proxy/db/spend_counter_reseed.py (lines 200–207) is the one area worth a second look.

Important Files Changed

Filename Overview
litellm/proxy/db/spend_counter_reseed.py Switches the reseed write from INCRBYFLOAT to SET NX to prevent multi-pod spend inflation; the TOCTOU fallback back to INCRBYFLOAT is very narrow but still theoretically reachable
tests/test_litellm/proxy/test_proxy_server.py Test stubs updated to model the new SET NX path; existing assertions restructured (not weakened) and two new tests added for multi-pod race and the TOCTOU edge case

Reviews (1): Last reviewed commit: "fix(proxy): make spend counter reseed id..." | Re-trigger Greptile

Comment on lines +200 to +207
if current_cached_value is None:
current_value = (
await spend_counter_cache.redis_cache.async_increment(
key=counter_key,
value=db_spend,
refresh_ttl=True,
)
)

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 TOCTOU fallback still exposes a narrow multi-pod inflation window

When N pods all lose the SET NX race (key was set by another pod) and then the winner's key expires before each loser's GET returns, all N losers fall into the async_increment(db_spend) branch. Since INCRBYFLOAT on a non-existent key starts from 0, the final counter ends up at (N-1) * db_spend. This is identical to the race the fix is designed to prevent, just in a much narrower window — the key must expire within the round-trip of a single Redis call. The PR description acknowledges this, and the existing coalesced_window() already uses the same pattern, so this is a deliberate trade-off rather than a new regression.

@oss-pr-review-agent-shin

Copy link
Copy Markdown
Contributor

🤖 litellm-agent: This PR is currently BLOCKED from merge.

Score: 3/5

Why blocked:

  • 1 PR-related CI failure (This PR will be auto-closed as it lacks a screenshot for proof of fix. Please include one in the PR description. Add the screenshot-exempt label if this PR has no visible output (e.g. pure docs, CI config).) (pr_related_failures, -2 pts)

Details: Score docked for: 1 PR-related CI failure (This PR will be auto-closed as it lacks a screenshot for proof of fix. Please include one in the PR description. Add the screenshot-exempt label if this PR has no visible output (e.g. pure docs, CI config).).

Fix the issues above and push an update — the bot will re-review automatically.

Note: This bot is still in beta and might not always work as expected. Please share any feedback via Slack.

@silencedoctor
silencedoctor changed the base branch from litellm_oss_staging to litellm_internal_staging May 25, 2026 03:37
@silencedoctor

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #27854, which was merged into litellm_internal_staging on 2026-05-20 (2 days before this PR was opened) and ships the equivalent SET NX fix:

  • Same file/function: SpendCounterReseed.coalesced() in litellm/proxy/db/spend_counter_reseed.py
  • Same approach: seed via async_set_cache(..., nx=True), winner writes in-memory cache, losers read the winner's value, per-request delta still goes through INCRBYFLOAT
  • Equivalent test coverage in tests/test_litellm/proxy/test_proxy_server.py using a nx=True && key exists -> False fake_redis

Closing to avoid duplicate review.

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.

1 participant