Visitar URL original
fix(proxy): invalidate end-user spend counter and cache on budget reset (#39726) by amasen02 · Pull Request #39729 · BerriAI/litellm · GitHub
Skip to content

fix(proxy): invalidate end-user spend counter and cache on budget reset (#39726) - #39729

Merged
mateo-berri merged 7 commits into
BerriAI:litellm_internal_stagingfrom
amasen02:fix/end-user-budget-reset-cache-invalidation-39726
Sep 5, 2026
Merged

mateo-berri merged 7 commits into
BerriAI:litellm_internal_stagingfrom
amasen02:fix/end-user-budget-reset-cache-invalidation-39726

Conversation

@amasen02

@amasen02 amasen02 commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • An end-user budget reset zeroes the DB spend but not the cached spend counter
  • Requests keep getting HTTP 429 after the window rolls over, until the caches expire
  • The reset job evicts the cached end-user object only on its own worker
  • Every other worker or replica keeps enforcing the pre-reset spend for another cache TTL

How it solves it:

  • The reset zeroes spend:end_user:<id> in memory and Redis and evicts the cached end-user object
  • Rollover carry now applies to explicit and default end-user budgets alike
  • Enforcement floors a cached end-user spend on LiteLLM_EndUserTable.spend when the shared counter sits below it
  • That floor read only runs in that mismatch and is cached in-process for 5s, like the key and team floors
  • Regression tests cover the reset, the eviction, the stale-worker floor, and the DB-free cold counter path

User Flow

Before: a developer whose app tags requests with an end-user id keeps getting budget-exceeded errors after that end user's budget window has already rolled over

  1. The proxy admin creates the default end-user budget with POST https://litellm-domain/budget/new and body {"budget_id": "default-end-user-budget", "max_budget": 0.00008, "budget_duration": "30s"}, then sets max_end_user_budget_id: default-end-user-budget under litellm_settings
  2. The developer sends POST https://litellm-domain/v1/chat/completions requests with header x-litellm-end-user-id: customer-42, spread across the proxy's replicas; the first five return HTTP 200 and use up the $0.00008 budget, the sixth returns HTTP 429 Budget has been exceeded
  3. They wait for the 30-second budget window to roll over
  4. They send five requests in the new window, spread across the replicas the same way, and every one returns HTTP 429 even though the window has reset
  5. Only after they stop sending requests for a full 60 seconds does the next request return HTTP 200

After: the same developer's requests go through as soon as the budget window rolls over, whichever replica or worker serves them

  1. The proxy admin creates the default end-user budget with POST https://litellm-domain/budget/new and body {"budget_id": "default-end-user-budget", "max_budget": 0.00008, "budget_duration": "30s"}, then sets max_end_user_budget_id: default-end-user-budget under litellm_settings
  2. The developer sends POST https://litellm-domain/v1/chat/completions requests with header x-litellm-end-user-id: customer-42, spread across the proxy's replicas; the first five return HTTP 200 and use up the $0.00008 budget, the sixth returns HTTP 429 Budget has been exceeded
  3. They wait for the 30-second budget window to roll over
  4. They send five requests in the new window, spread across the replicas the same way, and every one returns HTTP 200 with a completion, charged against the fresh window
  5. GET https://litellm-domain/customer/info?end_user_id=customer-42 shows the spend accruing again from 0 in the new window, and the next rollover clears it the same way

Relevant issues

Fixes #39726

Linear ticket

Resolves LIT-6977

Pre-Submission checklist

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

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • 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

Both legs run the same rig: two proxy replicas booted from the leg's commit, each with --num_workers 2, sharing one fresh Postgres database (a dedicated postgres:16-alpine container) and one Redis (redis:7-alpine), with requests alternating between replica A and replica B so state written through one worker is enforced by another. Every chat completion is a real Anthropic call to claude-haiku-4-5 with max_tokens: 1 (about $0.000016 each). The reset job interval is pinned to 5-6s so a 30s budget window rolls over quickly. The master key is shown as sk-***. Before ran on replica A :49495 and B :50692, After on A :35452 and B :44694.

Config used by both legs (only the Redis port differs):

model_list:
  - model_name: claude-haiku-4-5
    litellm_params:
      model: anthropic/claude-haiku-4-5
      api_key: os.environ/ANTHROPIC_API_KEY
litellm_settings:
  max_end_user_budget_id: default-end-user-budget
  cache: true
  cache_params:
    type: redis
    host: 127.0.0.1
    port: <redis port>
    supported_call_types: []
general_settings:
  proxy_budget_rescheduler_min_time: 5
  proxy_budget_rescheduler_max_time: 6

Chat request used in every step below (the port and the end-user header vary per step; the nonce keeps the prompt unique):

curl -s -w '\nHTTP %{http_code}' http://127.0.0.1:<port>/v1/chat/completions -H 'Authorization: Bearer sk-***' -H 'x-litellm-end-user-id: <end user>' -H 'Content-Type: application/json' -d '{"model":"claude-haiku-4-5","messages":[{"role":"user","content":"hi <nonce>"}],"max_tokens":1}'

Chat responses are trimmed to id, content, usage, and the HTTP status; /customer/info and /budget/info responses to the fields that matter.

Before (c8635ec)

Default end-user budget across a rollover, two replicas

  1. The admin creates the default end-user budget (30s window, $0.00008):

    $ curl -s http://127.0.0.1:49495/budget/new -H 'Authorization: Bearer sk-***' -H 'Content-Type: application/json' -d '{"budget_id":"default-end-user-budget","max_budget":0.00008,"budget_duration":"30s"}'
    {"budget_id": "default-end-user-budget", "max_budget": 8e-05, "budget_duration": "30s", "budget_reset_at": "2026-09-05T00:36:30Z"}
    
  2. The developer sends chat requests for customer-42, alternating replica A and B, until the budget is used up:

    request 1 via A at 00:36:15  {"id": "chatcmpl-3c4f1cfc-1d7a-4b72-a81d-7b252fdab1df", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 2 via B at 00:36:17  {"id": "chatcmpl-4dd5b077-4458-440b-8ada-3aebaec55ec4", "content": "Hey", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 3 via A at 00:36:19  {"id": "chatcmpl-5bffe746-5320-457b-81ec-fa6d44b26e74", "content": "Hello", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 4 via B at 00:36:21  {"id": "chatcmpl-b4ef1294-32ec-4a50-9926-9f5f51f58ce1", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 5 via A at 00:36:23  {"id": "chatcmpl-7924588f-7d74-46e3-b4ec-4782e5a90b16", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 6 via B at 00:36:25  {"error": {"message": "Budget has been exceeded! EndUser=customer-42 Current cost: 9.5e-05, Max budget: 8e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    
  3. The window rolls over (the reset job runs every 5-6s), and the admin view shows the reset:

    $ curl -s http://127.0.0.1:49495/budget/info -H 'Authorization: Bearer sk-***' -H 'Content-Type: application/json' -d '{"budgets":["default-end-user-budget"]}'
    {"budget_id": "default-end-user-budget", "max_budget": 8e-05, "budget_duration": "30s", "budget_reset_at": "2026-09-05T00:37:00Z"}     # was 2026-09-05T00:36:30Z, observed at 00:36:52
    $ curl -s 'http://127.0.0.1:49495/customer/info?end_user_id=customer-42' -H 'Authorization: Bearer sk-***'
    {"user_id": "customer-42", "spend": 1.6e-05, "budget_id": null, "blocked": false}
    
  4. Five requests in the new window, alternating A and B, and every one is rejected with the pre-reset spend:

    post-rollover request 1 via A at 00:36:53  {"error": {"message": "Budget has been exceeded! EndUser=customer-42 Current cost: 9.5e-05, Max budget: 8e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    post-rollover request 2 via B at 00:36:54  {"error": {"message": "Budget has been exceeded! EndUser=customer-42 Current cost: 9.5e-05, Max budget: 8e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    post-rollover request 3 via A at 00:36:55  {"error": {"message": "Budget has been exceeded! EndUser=customer-42 Current cost: 9.5e-05, Max budget: 8e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    post-rollover request 4 via B at 00:36:56  {"error": {"message": "Budget has been exceeded! EndUser=customer-42 Current cost: 9.5e-05, Max budget: 8e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    post-rollover request 5 via A at 00:36:57  {"error": {"message": "Budget has been exceeded! EndUser=customer-42 Current cost: 9.5e-05, Max budget: 8e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    
  5. 12s later nothing was charged in the new window:

    $ curl -s 'http://127.0.0.1:50692/customer/info?end_user_id=customer-42' -H 'Authorization: Bearer sk-***'
    {"user_id": "customer-42", "spend": 0.0, "budget_id": null, "blocked": false}
    

Explicit end-user budget (budget_id on the customer) across a rollover

  1. The admin creates a second 30s budget ($0.00003) and pins a customer to it:

    $ curl -s http://127.0.0.1:49495/budget/new -H 'Authorization: Bearer sk-***' -H 'Content-Type: application/json' -d '{"budget_id":"explicit-eu-budget","max_budget":0.00003,"budget_duration":"30s"}'
    {"budget_id": "explicit-eu-budget", "max_budget": 3e-05, "budget_duration": "30s", "budget_reset_at": "2026-09-05T00:38:00Z"}
    $ curl -s http://127.0.0.1:50692/customer/new -H 'Authorization: Bearer sk-***' -H 'Content-Type: application/json' -d '{"user_id":"customer-explicit","budget_id":"explicit-eu-budget"}'
    {"user_id": "customer-explicit", "spend": 0.0, "budget_id": "explicit-eu-budget", "blocked": false}
    
  2. Chat requests for customer-explicit until the budget is used up:

    request 1 via A at 00:37:57  {"id": "chatcmpl-442295a8-fe3e-44b9-bbbf-7f842dca487f", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 2 via B at 00:38:00  {"id": "chatcmpl-823742b4-59d5-48c8-9e3f-0aae0b20cdfb", "content": "Hey", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 3 via A at 00:38:04  {"error": {"message": "ExceededBudget: End User=customer-explicit over budget. Spend=3.2e-05, Budget=3e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    
  3. The window rolls over (budget_reset_at 2026-09-05T00:38:00Z -> 2026-09-05T00:38:30Z, observed at 00:38:28)

  4. The first two requests in the new window are still rejected:

    first request in the new window via A at 00:38:29   {"error": {"message": "ExceededBudget: End User=customer-explicit over budget. Spend=3.2e-05, Budget=3e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    second request in the new window via B at 00:38:29  {"error": {"message": "ExceededBudget: End User=customer-explicit over budget. Spend=3.2e-05, Budget=3e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    

New window keeps counting and spend re-accrues

  1. About 90s after the last customer-42 request (so the 60s cache has expired on every worker), the default budget rolls over again (budget_reset_at 2026-09-05T00:38:30Z -> 2026-09-05T00:39:00Z) and three back-to-back requests go through:

    new window, request 1 via A at 00:38:31  {"id": "chatcmpl-29ae703c-241e-4d47-b7c7-d37112a10f3c", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    new window, request 2 via B at 00:38:32  {"id": "chatcmpl-4028b834-fa2b-4422-a071-595d81169881", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    new window, request 3 via A at 00:38:33  {"id": "chatcmpl-f27f982a-b497-47c8-b0f4-ea72a057c982", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    
  2. 12s later the spend for the new window is visible:

    $ curl -s 'http://127.0.0.1:50692/customer/info?end_user_id=customer-42' -H 'Authorization: Bearer sk-***'
    {"user_id": "customer-42", "spend": 4.8e-05, "budget_id": null, "blocked": false}
    

After (89086db)

The commits since (b4fd63f, 4ffd2ff) only correct two comments in proxy_server.py and annotate or parametrize test locals, so they cannot change behavior and the run above stands.

Default end-user budget across a rollover, two replicas

  1. The admin creates the default end-user budget (30s window, $0.00008):

    $ curl -s http://127.0.0.1:35452/budget/new -H 'Authorization: Bearer sk-***' -H 'Content-Type: application/json' -d '{"budget_id":"default-end-user-budget","max_budget":0.00008,"budget_duration":"30s"}'
    {"budget_id": "default-end-user-budget", "max_budget": 8e-05, "budget_duration": "30s", "budget_reset_at": "2026-09-05T00:41:00Z"}
    
  2. The developer sends chat requests for customer-42, alternating replica A and B, until the budget is used up:

    request 1 via A at 00:40:42  {"id": "chatcmpl-83f0e770-b459-45ff-9910-bf2090978ef2", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 2 via B at 00:40:44  {"id": "chatcmpl-648beffd-cba9-48c3-855a-8f9c2966ba6e", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 3 via A at 00:40:45  {"id": "chatcmpl-5a382b32-c582-46c4-8144-78df6ffa737c", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 4 via B at 00:40:47  {"id": "chatcmpl-6ed57532-34b2-4414-810f-c43ef3eb8248", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 5 via A at 00:40:49  {"id": "chatcmpl-9eeed1b8-580c-4076-893c-cf88aa370f3f", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 6 via B at 00:40:51  {"error": {"message": "Budget has been exceeded! EndUser=customer-42 Current cost: 9.5e-05, Max budget: 8e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    
  3. The window rolls over, and the admin view shows the reset:

    $ curl -s http://127.0.0.1:35452/budget/info -H 'Authorization: Bearer sk-***' -H 'Content-Type: application/json' -d '{"budgets":["default-end-user-budget"]}'
    {"budget_id": "default-end-user-budget", "max_budget": 8e-05, "budget_duration": "30s", "budget_reset_at": "2026-09-05T00:41:30Z"}     # was 2026-09-05T00:41:00Z, observed at 00:41:20
    $ curl -s 'http://127.0.0.1:35452/customer/info?end_user_id=customer-42' -H 'Authorization: Bearer sk-***'
    {"user_id": "customer-42", "spend": 0.0, "budget_id": null, "blocked": false}
    
  4. Five requests in the new window, alternating A and B, and every one goes through:

    post-rollover request 1 via A at 00:41:20  {"id": "chatcmpl-303b6d31-b5a0-4e16-8946-b6d3cf228d64", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    post-rollover request 2 via B at 00:41:22  {"id": "chatcmpl-10cbab68-006f-45cf-bfda-af07f12853f8", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    post-rollover request 3 via A at 00:41:24  {"id": "chatcmpl-e6f18663-7bdb-41ca-aaef-f10a4e8dc1b7", "content": "Hello", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    post-rollover request 4 via B at 00:41:25  {"id": "chatcmpl-401b5c96-ef3b-4c4f-9e48-fbb9e63e89a8", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    post-rollover request 5 via A at 00:41:27  {"id": "chatcmpl-9e60e0a1-fc59-4149-9a9a-9c39f63d7228", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    
  5. 12s later the new window's spend is accruing from 0:

    $ curl -s 'http://127.0.0.1:44694/customer/info?end_user_id=customer-42' -H 'Authorization: Bearer sk-***'
    {"user_id": "customer-42", "spend": 6.4e-05, "budget_id": null, "blocked": false}
    

Explicit end-user budget (budget_id on the customer) across a rollover

  1. The admin creates a second 30s budget ($0.00003) and pins a customer to it:

    $ curl -s http://127.0.0.1:35452/budget/new -H 'Authorization: Bearer sk-***' -H 'Content-Type: application/json' -d '{"budget_id":"explicit-eu-budget","max_budget":0.00003,"budget_duration":"30s"}'
    {"budget_id": "explicit-eu-budget", "max_budget": 3e-05, "budget_duration": "30s", "budget_reset_at": "2026-09-05T00:45:00Z"}
    $ curl -s http://127.0.0.1:44694/customer/new -H 'Authorization: Bearer sk-***' -H 'Content-Type: application/json' -d '{"user_id":"customer-explicit","budget_id":"explicit-eu-budget"}'
    {"user_id": "customer-explicit", "spend": 0.0, "budget_id": "explicit-eu-budget", "blocked": false}
    
  2. Chat requests for customer-explicit until the budget is used up:

    request 1 via A at 00:44:33  {"id": "chatcmpl-4adabc20-3e19-47c6-b260-3cf2b4231a4e", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 2 via B at 00:44:36  {"id": "chatcmpl-b73693cf-6eec-48fe-be38-26b1f55ce573", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    request 3 via A at 00:44:40  {"error": {"message": "ExceededBudget: End User=customer-explicit over budget. Spend=3.2e-05, Budget=3e-05", "type": "budget_exceeded", "param": null, "code": "429"}}  HTTP 429
    
  3. The window rolls over (budget_reset_at 2026-09-05T00:45:00Z -> 2026-09-05T00:45:30Z, observed at 00:45:04)

  4. The first two requests in the new window go through:

    first request in the new window via A at 00:45:04   {"id": "chatcmpl-deb88624-f0db-407a-b2be-ddf9ba9a4a00", "content": "Hello", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    second request in the new window via B at 00:45:05  {"id": "chatcmpl-4665aff6-f62a-4050-82fc-ddcc3c64915f", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    

New window keeps counting and spend re-accrues

  1. The default budget rolls over again (budget_reset_at 2026-09-05T00:45:30Z -> 2026-09-05T00:46:00Z) and three back-to-back requests go through:

    new window, request 1 via A at 00:45:30  {"id": "chatcmpl-57fba407-d929-4aeb-b743-1d0cdeb422ff", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    new window, request 2 via B at 00:45:31  {"id": "chatcmpl-11c5a222-e9c1-40fe-8f4c-c5132d7301db", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    new window, request 3 via A at 00:45:32  {"id": "chatcmpl-032f3d53-3f6e-4445-a80d-02014a6d3556", "content": "#", "usage": {"prompt_tokens": 11, "completion_tokens": 1}}  HTTP 200
    
  2. 12s later the spend for the new window is visible:

    $ curl -s 'http://127.0.0.1:44694/customer/info?end_user_id=customer-42' -H 'Authorization: Bearer sk-***'
    {"user_id": "customer-42", "spend": 4.8e-05, "budget_id": null, "blocked": false}
    

Observations from the run:

  • Before leg: a batched spend write landed after the reset
    • /customer/info briefly showed pre-reset spend in the new window
    • timing-dependent and pre-existing; this PR leaves it alone
  • Default and explicit end-user budgets return different 429 messages
    • pre-existing; this PR leaves it alone
  • /customer/info spend lags the counter by the batch write interval
    • pre-existing on both legs; this PR leaves it alone

Type

🐛 Bug Fix

Caveats (if any)

Low

  • With fail_closed_budget_enforcement on, end-user checks now floor on the DB row too
    • one primary-key read per worker per 5s while the counter trails the cached spend
  • A reset drops a pre-reset reservation, like every other entity in the cascade
    • pre-existing; the reseeded floor is the just-reset DB value
  • Rollover carry for end users (budget_rollover: true) is unit-tested, not exercised live
  • A stale-low Redis reload during the spend batch-write lag can under-enforce for one flush interval
    • the cached end user runs ahead of the row until the next flush, like keys and teams already do

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

Note

Medium Risk
Changes budget enforcement and reset side effects for end users across workers and Redis; behavior is well-tested but touches multi-replica cache/counter consistency on a customer-facing path.

Overview
Fixes end-user budgets staying blocked after a window rollover because Redis/in-memory spend:end_user:<id> counters and per-worker cached end-user objects were not aligned with LiteLLM_EndUserTable after the reset job ran.

The budget reset cascade now treats end users like keys/teams/tags: it resets spend:end_user:<id> (including rollover carry for explicit and default max_end_user_budget_id budgets), and evicts the end-user management cache key. get_current_spend can floor enforcement on LiteLLM_EndUserTable.spend via new SpendCounterReseed.end_user_from_db (cold reseed still uses auth fallback_spend; the row read is only for stale-low / post-reset mismatch). _floor_spend_from_db centralizes that path alongside existing entity/window floors.

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

…et (BerriAI#39726)

Signed-off-by: amasen02 <amasen02@users.noreply.github.com>
@CLAassistant

CLAassistant commented Sep 4, 2026 •

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 all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ mateo-berri
❌ amasen02
You have signed the CLA already but the status is still pending? Let us recheck it.

@codspeed

codspeed Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing amasen02:fix/end-user-budget-reset-cache-invalidation-39726 (4ffd2ff) with litellm_internal_staging (59d42d3)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR synchronizes end-user spend state when budget windows reset and improves stale-worker enforcement.

  • Resets end-user in-memory and Redis spend counters alongside the database reset.
  • Evicts cached end-user objects and supports rollover carry for explicit and default end-user budgets.
  • Uses the resettable end-user database row as a bounded authoritative floor when cached spend state disagrees.
  • Adds regression coverage for reset invalidation, rollover carry, stale-worker recovery, and database-free counter paths.
  • The latest revision parametrizes the end-user/tag fallback test so each case receives a separate Final binding.

Confidence Score: 5/5

The PR appears safe to merge; no new actionable defect remains after the latest test-only correction.

The latest change preserves both fallback test cases while eliminating the reassignment of a Final binding. The earlier missing-annotation finding is fixed in the current code. The database-floor and reservation-loss findings were withdrawn after the developers’ explanations were accepted. Two test annotation threads were manually resolved without explanatory replies, and their cited issues are fixed in the current code.

Important Files Changed

Filename Overview
litellm/proxy/common_utils/reset_budget_job.py Adds end-user counter resets, cache eviction, and rollover-carry handling to the budget reset cascade.
litellm/proxy/db/spend_counter_reseed.py Adds a bounded end-user-row lookup for resolving stale cached spend mismatches.
litellm/proxy/proxy_server.py Routes end-user authoritative-floor checks through the new end-user database lookup.
tests/test_litellm/proxy/common_utils/test_reset_budget_job.py Covers end-user counter invalidation, object eviction, and default-budget rollover carry.
tests/test_litellm/proxy/db/test_spend_counter_reseed.py Covers end-user database floor reads, missing rows, errors, and non-end-user counter keys.
tests/test_litellm/proxy/proxy_server/test_spend_counters.py Covers stale-worker reset recovery and parametrizes fallback behavior without reassigning a Final local.

Reviews (5): Last reviewed commit: "test(proxy): parametrize the stale end-u..." | Re-trigger Greptile

@@ -674,6 +691,10 @@ async def _collect_budget_cascade(self, budgets_to_reset: Sequence[LiteLLM_Budge
(_model_access_group_counter_key(row), _row_carried_spend(row, rollover_caps))
for row in model_access_groups

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.

P1 Reset erases reserved spend

A pre-reset reservation can reconcile afterward. This absolute reset erases it, and without end-user reseeding, later requests can exceed the budget.

Knowledge Base Used: Spend, budgets, and rate limits

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!

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.

Keys, teams, orgs, tags, and access groups reset with the same absolute set in this cascade; the reseed floor is the just-reset DB value, so it cannot restore a reservation either.

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.

You're right. The end-user path follows the same cascade behavior as keys, teams, orgs, tags, and access groups: counters are reseeded from the collected DB spend, so this change does not introduce a distinct reservation-loss regression. The reservation concern is a broader pre-existing behavior rather than an actionable issue with this PR, so I’m withdrawing this comment.

def _enduser_carried_spend(row: _EndUserRow, caps: Mapping[str, float]) -> float:
if not caps:
return 0.0
effective_budget_id = row.budget_id or litellm.max_end_user_budget_id

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 Final annotations are missing

effective_budget_id and the new test locals lack required Final annotations. This repository requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

@codecov

codecov Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mateo-berri
mateo-berri requested a review from a team September 4, 2026 23:42
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

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.

Stale Bugbot comment from a previous run.

The reset job evicts the cached end-user object only from its own worker's
in-memory cache (plus Redis), so every other uvicorn worker and replica keeps
the pre-reset spend for up to user_api_key_cache_ttl (60s by default). Those
workers pass that stale spend as fallback_spend, and since the authoritative
floor read returned None for spend:end_user: keys, get_current_spend handed
the stale value straight back and the end user kept getting 429 after the
rollover on every worker but the one that ran the reset.

The floor read now consults LiteLLM_EndUserTable.spend for end-user counters,
the same way keys, teams, users, and orgs already read their rows. It runs only
when the shared counter sits below the cached spend (a reset or a Redis
restart) and stays behind the existing 5s in-process marker, so the normal
request path still does no DB read. Cold end-user counters keep seeding from
the cached object rather than the row, so from_db is unchanged for them.
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

where: Final[LiteLLM_EndUserTableWhereUniqueInput] = {"user_id": counter_key[len(END_USER_COUNTER_PREFIX) :]}
try:
row: Final = await EndUserRepository(prisma_client).table.find_unique(where=where)
except Exception: # noqa: BLE001 # a failed floor read falls back to the cached spend, like from_db

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.

P1 Critical path database lookup

End-user budget checks can now directly query the database when the spend-floor cache is cold. This violates the directive against database queries in critical request paths and can increase request latency and database load

Rule Used: What: In critical path of request, there should be... (source)

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.

Same bounded read keys and teams already do here: one primary-key lookup per worker per 5s, only while the counter trails the cached spend.

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.

You're right. I re-evaluated this against the existing authoritative-floor path: the read is bounded by the existing per-counter floor-cache window, uses a primary-key lookup, and only runs when the counter is below the cached spend. That is the same behavior already accepted for key/team counters, and EndUserRepository keeps the query behind the repository abstraction. I’m withdrawing this finding; no change is required for this comment.

Comment thread tests/test_litellm/proxy/proxy_server/test_spend_counters.py
window_start: datetime | None,
) -> float | None:
if counter_key.startswith(END_USER_COUNTER_PREFIX):
return await SpendCounterReseed.end_user_from_db(prisma_client=prisma_client, counter_key=counter_key)

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.

Low: Lagging database spend can bypass the budget

End-user spend is batch-flushed, so this row can be lower than both fallback_spend and the actual spend. If Redis reloads a stale-low counter, this branch causes get_current_spend() to discard the higher fallback and admit an end user already over budget; use a reset generation/timestamp or cross-worker reset invalidation to distinguish a genuine reset from ordinary database lag before allowing the row to lower the effective spend.

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.

Same bounded window keys and teams already take: the cache leads the batch-flushed row for one flush interval, and only with a stale-low Redis reload.

@veria-ai

veria-ai Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

This pull request updates budget-reset handling to invalidate and reload end-user spend counters and cached spend data in the proxy.

Two budget-accounting race conditions remain open. Database lag can replace a higher fallback spend value with stale data, while concurrent reservations can be overwritten during a reset; either case may temporarily allow an end user to exceed their budget. No reported issues have yet been addressed.

Open issues (2)

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

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment thread tests/test_litellm/proxy/proxy_server/test_spend_counters.py Outdated
(_model_access_group_counter_key(row), _row_carried_spend(row, rollover_caps))
for row in model_access_groups
),
*((_enduser_counter_key(row), _enduser_carried_spend(row, rollover_caps)) for row in endusers),

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.

Low: Concurrent reservations are erased by the reset

This adds end users to the post-commit counter resets, which unconditionally overwrite Redis. An end user can submit a request during the interval after the database commit but before this reset; its pre-request reservation increments the same counter and is then replaced with new_spend, allowing that request's cost to be omitted until the lagging database spend catches up. Use an atomic reset protocol that preserves increments occurring after the reset commit, such as a versioned/window-specific counter or coordination between reset and reservation.

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.

Keys, teams, orgs, and tags reset with the same absolute set in this cascade; end users now match that pre-existing behavior.

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

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 4ffd2ff. Configure here.

@mateo-berri mateo-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. Thanks for the contribution!

@mateo-berri
mateo-berri merged commit 3d08dae into BerriAI:litellm_internal_staging Sep 5, 2026
185 of 187 checks passed
yuneng-berri added a commit that referenced this pull request Sep 25, 2026
…udget_0924

chore(release): backport #39631, #39729, #40639 to stable/1.98.x
yuneng-berri added a commit that referenced this pull request Sep 25, 2026
…udget_0924

chore(release): backport #39631, #39729, #40639 to stable/1.99.x and cut 1.99.4
yuneng-berri added a commit that referenced this pull request Sep 25, 2026
…budget_0924

chore(release): backport #39631, #39729, #40639 to stable/1.100.x and cut 1.100.3
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]: End-user budget reset does not invalidate spend cache

3 participants