Skip to content

feat(proxy): enqueued-token rate limiting for batches with refund on completion and cancellation - #37539

Merged
mateo-berri merged 9 commits into
litellm_internal_stagingfrom
litellm_batch_enqueued_token_limit
Aug 20, 2026
Merged

feat(proxy): enqueued-token rate limiting for batches with refund on completion and cancellation#37539
mateo-berri merged 9 commits into
litellm_internal_stagingfrom
litellm_batch_enqueued_token_limit

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Batch submissions count against per-minute RPM/TPM, so large batches always 429
  • The only workaround, disable_batch_input_file_rate_limiting, drops all batch quota control

How it solves it:

  • Opt-in batch_enqueued_token_limit in key or team metadata
  • Batch create reserves the file's estimated tokens against that allowance
  • Reservation is refunded when the batch completes, fails, expires, or is cancelled
  • Rejections happen at the gateway, before the provider sees the batch
  • Online /chat/completions RPM/TPM behavior is unchanged
  • Only proxy admins can set or change the metadata field; a non-admin write is 403 (it would let a key holder lift their own batch quota)

User Flow

Before: a team whose batch jobs outsize their per-minute limits cannot submit batches at all without turning batch rate limiting off entirely

  1. The proxy admin creates the team's key: POST https://litellm-domain/key/generate with {"rpm_limit": 2, "tpm_limit": 10}
  2. The developer uploads a 3-request JSONL file: POST https://litellm-domain/v1/files with purpose=batch, getting back a file-... id
  3. They submit it: POST https://litellm-domain/v1/batches with {"input_file_id": "file-..."}
  4. The create returns 429 "Batch rate limit exceeded ... 3 requests but only 2 requests remaining" and nothing reaches the provider
  5. The admin's only escape is disable_batch_input_file_rate_limiting: true, after which any key can enqueue unlimited batch work

After: the same team's key carries an enqueued-token allowance, so batch submission is governed by outstanding batch tokens instead of per-minute limits

  1. The proxy admin creates the team's key: POST https://litellm-domain/key/generate with {"rpm_limit": 2, "tpm_limit": 10, "metadata": {"batch_enqueued_token_limit": 100000}}
  2. The developer uploads a 3-request JSONL file: POST https://litellm-domain/v1/files with purpose=batch, getting back a file-... id
  3. They submit it: POST https://litellm-domain/v1/batches with {"input_file_id": "file-..."}
  4. The create returns 200 with a batch id and status validating, even though 3 rows exceed the key's 2 RPM
  5. A submission that would overrun the allowance returns 429 "Batch enqueued token limit exceeded ... Batch requires N tokens but only M enqueued tokens remaining out of 100000 enqueued token limit"
  6. Once a running batch completes or the developer cancels it via POST https://litellm-domain/v1/batches/{batch_id}/cancel, its held tokens free up and the blocked submission succeeds on retry
  7. Online POST https://litellm-domain/v1/chat/completions traffic on the same key still obeys the 2 RPM / 10 TPM limits unchanged

Relevant issues

Linear ticket

Resolves LIT-5273

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

Both runs hit a live proxy booted from a fresh worktree with a fresh Postgres DB, local Redis, and real OpenAI batches on gpt-5.4-mini (real $$). Before = merge base a1afc2f, After = PR tip d5ac495, same four cases each side. batch_input.jsonl is 5 tiny gpt-5.4-mini chat rows with max_completion_tokens: 16

Before (a1afc2f, merge base)

Case 1: batch over the key's RPM with an allowance in metadata, 429 and the allowance is ignored

curl -sS -w '\nHTTP %{http_code}\n' -X POST $BASE/key/generate -H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" \
  -d '{"rpm_limit":2,"metadata":{"batch_enqueued_token_limit":100000}}'
# HTTP 200 -> key sk-7Rz1UFWoH...

curl -sS -w '\nHTTP %{http_code}\n' -X POST "$BASE/v1/files?model=gpt-5.4-mini" -H "Authorization: Bearer $KEY" -F purpose=batch -F "file=@batch_input.jsonl"
# HTTP 200 -> "id":"file-bGl0ZWxsbTpmaWxlLTEzZ25SMUpoNXZiS1A4UVJzZFRDRlY7bW9kZWwsZ3B0LTUuNC1taW5p", "status":"processed"

curl -sS -w '\nHTTP %{http_code}\n' -X POST $BASE/v1/batches -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"input_file_id":"<FILE1>","endpoint":"/v1/chat/completions","completion_window":"24h"}'
# HTTP 429
# {"error":{"message":"Batch rate limit exceeded for api_key: 417172a5... Batch contains 5 requests
#   but only 2 requests remaining out of 2 RPM limit. Limit resets at: 2026-08-19 15:21:48 UTC",
#   "type":"internal_server_error","param":null,"code":"429"}}

Case 2: 1-token allowance and no per-minute limits, the batch sails through because nothing reads the allowance

curl -sS -w '\nHTTP %{http_code}\n' -X POST $BASE/key/generate -H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" \
  -d '{"metadata":{"batch_enqueued_token_limit":1}}'
# HTTP 200; same file upload -> HTTP 200

curl -sS -w '\nHTTP %{http_code}\n' -X POST $BASE/v1/batches -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"input_file_id":"<FILE2>","endpoint":"/v1/chat/completions","completion_window":"24h"}'
# HTTP 200 -> "id":"batch_bGl0ZWxsbTpiYXRjaF82YTg2MmM1ZTI2ZGM4MTkw...", "status":"validating"

Case 3: allowance sized to fit one batch, then cancel, every create is 200 because nothing is accounted

# key with {"batch_enqueued_token_limit":400}, same file upload (both HTTP 200)
curl -sS -X POST $BASE/v1/batches ...                    # batch 1: HTTP 200, "status":"validating"
curl -sS -X POST $BASE/v1/batches ...                    # batch 2, no cancel in between: HTTP 200, "status":"validating"
curl -sS -X POST "$BASE/v1/batches/<BATCH1>/cancel" ...  # HTTP 200, "status":"cancelling"
curl -sS -X POST $BASE/v1/batches ...                    # batch 3: HTTP 200, "status":"validating"

Case 4: online chat on a 2 RPM key with the allowance present, 200/200/429

curl -sS -w '\nHTTP %{http_code}\n' -X POST $BASE/v1/chat/completions -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"gpt-5.4-mini","messages":[{"role":"user","content":"say 1 <uuid>"}],"max_tokens":16}'
# 1st: HTTP 200 chatcmpl-EEixv6qGYromWUSswHbcQHW52lSeg
# 2nd: HTTP 200 chatcmpl-EEixwiPsLIJETYHMjAp4yCXJqv8OV
# 3rd: HTTP 429 "Rate limit exceeded for api_key: 36b069c1... Limit type: requests. Current limit: 2, Remaining: 0."

After (d5ac495, PR tip)

Case 1: the same over-RPM batch with the 100k allowance is now accepted

curl -sS -w '\nHTTP %{http_code}\n' -X POST $BASE/key/generate -H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" \
  -d '{"rpm_limit":2,"metadata":{"batch_enqueued_token_limit":100000}}'
# HTTP 200 -> key sk-5wQsRa_ac...

curl -sS -w '\nHTTP %{http_code}\n' -X POST "$BASE/v1/files?model=gpt-5.4-mini" -H "Authorization: Bearer $KEY" -F purpose=batch -F "file=@batch_input.jsonl"
# HTTP 200 -> "id":"file-bGl0ZWxsbTpmaWxlLUtSQ1duRHRuV2o2a3hDaG5IRVhkaEQ7bW9kZWwsZ3B0LTUuNC1taW5p"

curl -sS -w '\nHTTP %{http_code}\n' -X POST $BASE/v1/batches -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"input_file_id":"<FILE1>","endpoint":"/v1/chat/completions","completion_window":"24h"}'
# HTTP 200 -> "id":"batch_bGl0ZWxsbTpiYXRjaF82YTg2NjdlNTAyZmM4MTkw...", "status":"validating" (5 rows > 2 RPM, still accepted)

Case 2: the 1-token allowance now rejects at the gateway, before the provider sees anything

# key sk-2aUdrYVo6... with {"metadata":{"batch_enqueued_token_limit":1}}, file upload HTTP 200
curl -sS -w '\nHTTP %{http_code}\n' -X POST $BASE/v1/batches -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"input_file_id":"<FILE2>","endpoint":"/v1/chat/completions","completion_window":"24h"}'
# HTTP 429
# "Batch enqueued token limit exceeded for api_key: 4b03efae... Batch requires 65 tokens but only 1
#   enqueued tokens remaining out of 1 enqueued token limit. Tokens free up as running batches
#   complete or are cancelled."

The 429 names the reservation size N = 65 tokens, so the refund case sizes its allowance at 65 + 65//2 = 97

Case 3: 97-token allowance, exhaustion 429, cancel refunds, retry succeeds

# key sk-5uVWwCabz... with {"batch_enqueued_token_limit":97}, same file upload (HTTP 200)
curl -sS -X POST $BASE/v1/batches ...                    # batch 1: HTTP 200, "id":"batch_bGl0ZWxsbTpiYXRjaF82YTg2NjgwZjFiNjQ4MTkw...", "status":"validating"
curl -sS -X POST $BASE/v1/batches ...                    # batch 2, same file: HTTP 429
# "Batch enqueued token limit exceeded ... Batch requires 65 tokens but only 32 enqueued tokens
#   remaining out of 97 enqueued token limit."
curl -sS -X POST "$BASE/v1/batches/<BATCH1>/cancel" -H "Authorization: Bearer $KEY"
# HTTP 200, "status":"cancelling"
curl -sS -X POST $BASE/v1/batches ...                    # batch 3: HTTP 200, "id":"batch_bGl0ZWxsbTpiYXRjaF82YTg2NjgxMDQ3ZTQ4MTkw...", "status":"validating"

Redis on 6379 held the live batch_enqueued_tokens:api_key:be176bd8... counter at 65 after the refund (not 130), batch 1's popped reservation key held the empty-string tombstone (EXISTS=1, STRLEN=0) that blocks a second refund, and batch 3's reservation showed {"tokens":65,...,"backend":"redis","reserved_at_monotonic":176584.85...} with record and tombstone TTLs (691173, 691174) at or below the counter's (691174), so a reservation record can never outlive the counter it refunds; the proxy log shows zero in-memory-fallback lines for enqueued-token ops

Case 4: online chat on the same-shaped 2 RPM key, unchanged 200/200/429

# key sk-el-lAgk0X..., 3 back-to-back POST $BASE/v1/chat/completions with {"model":"gpt-5.4-mini",...,"max_tokens":16}
# 1st: HTTP 200 chatcmpl-EEmvwxEVo7Ty4jZlCNJIJrpXGtY2t
# 2nd: HTTP 200 chatcmpl-EEmvxaBURD0I35n1vrF1TvVWPwEku
# 3rd: HTTP 429 "Rate limit exceeded for api_key: 6871461c... Limit type: requests. Current limit: 2, Remaining: 0."

Admin gate: a non-admin cannot grant themselves an allowance

curl -sS -X POST $BASE/user/new -H "Authorization: Bearer $MASTER" -H "Content-Type: application/json" \
  -d '{"user_role":"internal_user","user_email":"lit5273-gate@example.com"}'
# HTTP 200 -> user key sk-gdsWCzyWK...

curl -sS -w '\nHTTP %{http_code}\n' -X POST $BASE/key/generate -H "Authorization: Bearer $USERKEY" -H "Content-Type: application/json" \
  -d '{"metadata":{"batch_enqueued_token_limit":100000}}'
# HTTP 403
# "Only proxy admins can set batch_enqueued_token_limit on a key. It replaces the standard rate
#   limit checks for batch submissions."

curl -sS -o /dev/null -w 'HTTP %{http_code}\n' -X POST $BASE/key/generate -H "Authorization: Bearer $USERKEY" -H "Content-Type: application/json" \
  -d '{"metadata":{"note":"no limit"}}'
# HTTP 200 (same caller, no gated field)

Surprises:

  • 429 envelope says "type":"internal_server_error" on both sides (pre-existing)
  • Before leg: batch RPM counts file rows per minute

Type

🆕 New Feature
✅ Test

Caveats (if any)

  • Opt-in via key/team metadata; no DB schema change
  • Writing batch_enqueued_token_limit is proxy-admin-only across key generate/update/regenerate/bulk update and team new/update; edit forms that resend the stored value unchanged stay allowed
  • Reservation size reuses the existing batch token estimate (input + max_tokens)
  • Reservations expire after 8 days, bounding leaks from proxy crashes; terminal statuses seen only by the enterprise cost poller or via passthrough routes refund on that TTL rather than immediately. Reservation records are clamped to expire no later than the counters they refund, so a stale record can never debit an allowance re-granted after its counters expired. Every reserve re-extends the shared counter's TTL to cover its newest reservation, so leaked tokens age out only once the key or team goes 8 days without submitting. Deleting the counter key in Redis resets a wedged allowance immediately
  • A submission that fails with an ambiguous provider outcome, for example a timeout after dispatch, refunds its reservation immediately. If the provider actually accepted that batch it runs outside the allowance until a terminal status is observed; with no batch id to reconcile against, holding the reservation would instead lock the allowance for 8 days on every transient failure
  • Legacy v1 rate limiting (LEGACY_MULTI_INSTANCE_RATE_LIMITING=true) has no batch handling, so the feature is a no-op there, matching existing batch limits
  • During a Redis outage the in-memory fallback is owner-scoped, so degraded mode can only tighten limits per worker, never widen them
  • Daily token windows (LIT-4351) are a separate follow-up
  • The red buildkite/e2e-tests check at the tip is inherited: staging's own scheduled e2e run fails the same audio-transcriptions assertion at the same revision with no PR involved, and the second failed test (an otel spend-row poll) did not fail there. Neither exercises this PR's code paths and the check is not required for merge

QA runbook

  • tests/e2e/batches/test_batches_e2e.py::TestBatchEnqueuedTokenLimit::test_enqueued_allowance_accepts_batch_over_key_rpm - a key with an enqueued-token allowance submits a batch whose row count exceeds its RPM
    • Generate a key: curl -X POST http://localhost:4000/key/generate -H "Authorization: Bearer sk-1234" -d '{"rpm_limit": 2, "metadata": {"batch_enqueued_token_limit": 100000}}'
    • Upload a 3-request JSONL: curl -X POST 'http://localhost:4000/v1/files?model=gpt-4o-mini' -H "Authorization: Bearer " -F purpose=batch -F file=@batch.jsonl
    • Create the batch: curl -X POST http://localhost:4000/v1/batches -H "Authorization: Bearer " -d '{"input_file_id": "", "endpoint": "/v1/chat/completions", "completion_window": "24h"}'
    • Expect 200 with a batch object despite 3 rows > 2 RPM
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/batches/test_batches_e2e.py::TestBatchEnqueuedTokenLimit::test_exhausted_allowance_blocks_until_cancel_refunds - an exhausted allowance rejects the create and cancelling the running batch refunds it
    • Generate a key with {"metadata": {"batch_enqueued_token_limit": 1}}, upload a 3-request JSONL with it, create the batch, and expect 429 naming "Batch enqueued token limit exceeded" plus "Batch requires N tokens"
    • Generate a second key with the limit set to N + N/2, upload the same JSONL, and expect the first create to return 200
    • Repeat the create on that key and expect 429 naming the enqueued token limit
    • Cancel the first batch: curl -X POST http://localhost:4000/v1/batches//cancel -H "Authorization: Bearer " and expect status cancelling
    • Repeat the create and expect 200, proving the refund freed the allowance
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky

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

  • d5ac495 passes /live-pr-risk


Note

Cursor Bugbot is generating a summary for commit d5ac495. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds an opt-in outstanding-token allowance for batch submissions, including reservation, terminal-state refund, and administrator-only configuration

  • Resolves key and team allowance scopes from metadata
  • Uses Redis-backed counters and per-batch reservation records with an in-memory fallback
  • Integrates reservation and refund handling into batch pre-call and post-call hooks
  • Adds management authorization checks and batch lifecycle coverage

Confidence Score: 4/5

The production change appears safe, but the live Redis test must be moved to the end-to-end suite before merging

The previously reported test isolation failure remains: the unit-test module probes localhost and executes against a real Redis server

Files Needing Attention: tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py

Important Files Changed

Filename Overview
litellm/proxy/hooks/batch_enqueued_tokens.py Adds Redis and in-memory accounting for outstanding batch-token reservations, including idempotent refund records
litellm/proxy/hooks/batch_rate_limiter.py Routes opted-in batch requests through the new enqueued-token allowance instead of ordinary per-minute charging
litellm/proxy/hooks/parallel_request_limiter_v3.py Persists successful batch reservations and refunds them on failure, cancellation, or terminal responses
litellm/proxy/auth/auth_utils.py Adds the shared authorization guard that prevents non-admins from changing the allowance
litellm/proxy/management_endpoints/key_management_endpoints.py Applies the administrator-only metadata guard across key creation, update, bulk update, and regeneration paths
litellm/proxy/management_endpoints/team_endpoints.py Applies the administrator-only metadata guard to team creation and updates
tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py Covers the accounting lifecycle but still places a live Redis integration test in the network-free unit-test tree

Reviews (9): Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." | Re-trigger Greptile

Comment thread litellm/proxy/hooks/batch_enqueued_tokens.py
Comment thread litellm/proxy/hooks/batch_enqueued_tokens.py
Comment thread tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py
Comment thread litellm/proxy/hooks/batch_enqueued_tokens.py
BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY: Final = "batch_enqueued_token_limit"

BATCH_ENQUEUED_REFUND_STATUSES: Final[frozenset[str]] = frozenset(
{"completed", "complete", "failed", "expired", "cancelled", "cancelling"}

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.

Medium: Nonterminal cancellations refund reservations

cancelling represents an ongoing provider transition, but including it here immediately releases the entire reservation. A caller can submit a batch, request cancellation until it returns cancelling, and submit another batch while the first remains nonterminal, repeatedly exceeding the configured enqueued-token allowance. Refund only after the provider reports an actual terminal state.

Suggested change
{"completed", "complete", "failed", "expired", "cancelled", "cancelling"}
{"completed", "complete", "failed", "expired", "cancelled"}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Immediate refund on cancellation is this feature's acceptance criterion; the cancelling-to-cancelled window is minutes, and the popped reservation record already prevents double refunds

@veria-ai

veria-ai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request adds enqueued-token rate limiting for proxy batch requests, reserving capacity when batches are submitted and refunding it when they complete or are cancelled. It also integrates reservation refunds into batch request failure handling.

Five issues have been addressed, but two refund paths can still release token reservations while provider-side batches may remain active. Callers could exploit cancellation transitions or post-dispatch timeouts to submit concurrent work beyond the configured enqueued-token allowance, weakening the intended resource-control boundary. Refunds should be limited to confirmed terminal states or requests known not to have been dispatched.

Open issues (2)

Fixed/addressed: 5 · PR risk: 6/10

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.22059% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/hooks/batch_enqueued_tokens.py 94.71% 11 Missing ⚠️
litellm/proxy/hooks/parallel_request_limiter_v3.py 91.30% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/hooks/batch_enqueued_tokens.py
… by backend, lowercase terminal statuses

Reserve-script failures now roll back the scopes already incremented before
re-raising into the in-memory fallback, so a partial redis outage no longer
leaks counter increments that shrink the shared allowance. Reservations
record which backend granted them, so a refund never debits redis counters
an in-memory grant did not charge. Terminal-status matching is now
case-insensitive because the Bedrock async-invoke retrieve path returns raw
AWS-cased statuses like Completed.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/hooks/batch_enqueued_tokens.py Outdated
…orker

In-memory grants now record an owner token, and a refund only debits local
counters when the popping worker is the one that granted them, so a terminal
response handled elsewhere can no longer shrink another worker's unrelated
fallback reservations. A Redis-granted refund that fails no longer falls back
to decrementing local counters either: the leaked Redis increments expire
with the TTL and only tighten the allowance.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/hooks/batch_rate_limiter.py

@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 2 potential issues.

Autofix Details

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

  • ✅ Fixed: Over-limit Redis errors grant quota
    • Routed the mid-reserve rollback through the best-effort _rollback_partial_reserve so a failing DECRBY on prior scopes no longer bubbles out of _reserve_via_redis and drops through to _reserve_in_memory, preserving the intended BatchEnqueuedTokenOverLimit response.
  • ✅ Fixed: In-memory reservation refunds are skipped
    • pop_reservation now falls back to _pop_local_record whenever the Redis pop returns None (not just on Redis exceptions), so cancel/complete refunds recover reservations that save_reservation had to stash locally when Redis was unavailable.

Create PR

Or push these changes by commenting:

@cursor push 3c4eecac5b
Preview (3c4eecac5b)
diff --git a/litellm/proxy/hooks/batch_enqueued_tokens.py b/litellm/proxy/hooks/batch_enqueued_tokens.py
--- a/litellm/proxy/hooks/batch_enqueued_tokens.py
+++ b/litellm/proxy/hooks/batch_enqueued_tokens.py
@@ -246,7 +246,7 @@ async def _reserve_via_redis(
                 already_reserved=scopes[:index],
             )
             if result[0] != 1:
-                await self._refund_via_redis(refund_script, tokens=tokens, scopes=scopes[:index])
+                await self._rollback_partial_reserve(refund_script, tokens=tokens, scopes=scopes[:index])
                 return BatchEnqueuedTokenOverLimit(scope=scope, enqueued=result[1])
         return BatchEnqueuedTokenReservation(tokens=tokens, scopes=scopes, backend="redis")
 

@@ -246,7 +246,7 @@ async def _reserve_via_redis(
                 already_reserved=scopes[:index],
             )
             if result[0] != 1:
-                await self._refund_via_redis(refund_script, tokens=tokens, scopes=scopes[:index])
+                await self._rollback_partial_reserve(refund_script, tokens=tokens, scopes=scopes[:index])
                 return BatchEnqueuedTokenOverLimit(scope=scope, enqueued=result[1])
         return BatchEnqueuedTokenReservation(tokens=tokens, scopes=scopes, backend="redis")
 
@@ -384,8 +384,7 @@ async def pop_reservation(
                 verbose_proxy_logger.warning(
                     "Redis enqueued-token reservation pop failed, falling back to in-memory: %s", str(e)
                 )
-                raw = await self._pop_local_record(batch_id, litellm_parent_otel_span)
-        else:
+        if raw is None:
             raw = await self._pop_local_record(batch_id, litellm_parent_otel_span)
         if raw is None:
             return None

@@ -384,8 +384,7 @@ async def pop_reservation(
                 verbose_proxy_logger.warning(
                     "Redis enqueued-token reservation pop failed, falling back to in-memory: %s", str(e)
                 )
-                raw = await self._pop_local_record(batch_id, litellm_parent_otel_span)
-        else:
+        if raw is None:
             raw = await self._pop_local_record(batch_id, litellm_parent_otel_span)
         if raw is None:
             return None

diff --git a/tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py b/tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py
--- a/tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py
+++ b/tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py
@@ -132,14 +132,25 @@ def __init__(
         self,
         fail_reserve_keys: frozenset[str] = frozenset(),
         fail_refund_keys: frozenset[str] = frozenset(),
+        fail_save_keys: frozenset[str] = frozenset(),
     ) -> None:
         self.script_calls: tuple[tuple[str, tuple[str, ...]], ...] = ()
         self.counters: Mapping[str, int] = MappingProxyType({})
+        self.records: Mapping[str, str] = MappingProxyType({})
         self.fail_reserve_keys = fail_reserve_keys
         self.fail_refund_keys = fail_refund_keys
+        self.fail_save_keys = fail_save_keys
 
     def async_register_script(self, script: str):
-        kind: Final = "reserve" if "INCRBY" in script else "refund" if "DECRBY" in script else "record"
+        kind: Final = (
+            "reserve"
+            if "INCRBY" in script
+            else "refund"
+            if "DECRBY" in script
+            else "save"
+            if "SET" in script
+            else "pop"
+        )
 
         async def run(keys: Sequence[str], args: Sequence[str | bytes | int | float]) -> object:
             self.script_calls = (*self.script_calls, (kind, tuple(keys)))

@@ -132,14 +132,25 @@ def __init__(
         self,
         fail_reserve_keys: frozenset[str] = frozenset(),
         fail_refund_keys: frozenset[str] = frozenset(),
+        fail_save_keys: frozenset[str] = frozenset(),
     ) -> None:
         self.script_calls: tuple[tuple[str, tuple[str, ...]], ...] = ()
         self.counters: Mapping[str, int] = MappingProxyType({})
+        self.records: Mapping[str, str] = MappingProxyType({})
         self.fail_reserve_keys = fail_reserve_keys
         self.fail_refund_keys = fail_refund_keys
+        self.fail_save_keys = fail_save_keys
 
     def async_register_script(self, script: str):
-        kind: Final = "reserve" if "INCRBY" in script else "refund" if "DECRBY" in script else "record"
+        kind: Final = (
+            "reserve"
+            if "INCRBY" in script
+            else "refund"
+            if "DECRBY" in script
+            else "save"
+            if "SET" in script
+            else "pop"
+        )
 
         async def run(keys: Sequence[str], args: Sequence[str | bytes | int | float]) -> object:
             self.script_calls = (*self.script_calls, (kind, tuple(keys)))
@@ -167,6 +178,15 @@ def _run(self, kind: str, keys: tuple[str, ...], args: tuple[str | bytes | int |
                 else {**self.counters, keys[0]: remaining}
             )
             return 1
+        if kind == "save":
+            if keys[0] in self.fail_save_keys:
+                raise ConnectionError(f"simulated redis failure for {keys[0]}")
+            self.records = MappingProxyType({**self.records, keys[0]: str(args[0])})
+            return 1
+        if kind == "pop":
+            value: Final = self.records.get(keys[0])
+            self.records = MappingProxyType({key: val for key, val in self.records.items() if key != keys[0]})
+            return value
         raise AssertionError(f"unexpected {kind} script call for keys {keys}")
 
 

@@ -167,6 +178,15 @@ def _run(self, kind: str, keys: tuple[str, ...], args: tuple[str | bytes | int |
                 else {**self.counters, keys[0]: remaining}
             )
             return 1
+        if kind == "save":
+            if keys[0] in self.fail_save_keys:
+                raise ConnectionError(f"simulated redis failure for {keys[0]}")
+            self.records = MappingProxyType({**self.records, keys[0]: str(args[0])})
+            return 1
+        if kind == "pop":
+            value: Final = self.records.get(keys[0])
+            self.records = MappingProxyType({key: val for key, val in self.records.items() if key != keys[0]})
+            return value
         raise AssertionError(f"unexpected {kind} script call for keys {keys}")
 
 
@@ -252,6 +272,39 @@ async def test_failed_redis_refund_leaves_local_counters_untouched():
     assert fake.counters == {counter_key: 60}
 
 
+@pytest.mark.asyncio
+async def test_over_limit_still_rejects_when_prior_scope_rollback_fails():
+    key_scope = _scope(limit=100, key="api_key")
+    team_scope = _scope(limit=50, key="team")
+    fake = _SingleKeyRedisFake(fail_refund_keys=frozenset({f"batch_enqueued_tokens:api_key:{key_scope.value}"}))
+    store = BatchEnqueuedTokenStore(
+        internal_usage_cache=InternalUsageCache(DualCache(redis_cache=fake, default_in_memory_ttl=60))
+    )
+    seed = await store.reserve(tokens=30, scopes=(key_scope, team_scope))
+    assert isinstance(seed, BatchEnqueuedTokenReservation)
+
+    outcome = await store.reserve(tokens=25, scopes=(key_scope, team_scope))
+    assert outcome == BatchEnqueuedTokenOverLimit(scope=team_scope, enqueued=30)
+
+
+@pytest.mark.asyncio
+async def test_pop_reservation_falls_back_to_local_when_save_wrote_locally():
+    key_scope = _scope(limit=100, key="api_key")
+    record_key: Final = "batch_enqueued_token_reservation:batch_local"
+    fake = _SingleKeyRedisFake(fail_save_keys=frozenset({record_key}))
+    store = BatchEnqueuedTokenStore(
+        internal_usage_cache=InternalUsageCache(DualCache(redis_cache=fake, default_in_memory_ttl=60))
+    )
+    reservation = BatchEnqueuedTokenReservation(tokens=40, scopes=(key_scope,), backend="redis")
+
+    await store.save_reservation("batch_local", reservation)
+    assert record_key not in fake.records
+
+    popped = await store.pop_reservation("batch_local")
+    assert popped == reservation
+    assert await store.pop_reservation("batch_local") is None
+
+
 @pytest.mark.asyncio
 async def test_pop_reservation_defaults_legacy_records_to_redis_backend():
     store = _in_memory_store()

@@ -252,6 +272,39 @@ async def test_failed_redis_refund_leaves_local_counters_untouched():
     assert fake.counters == {counter_key: 60}
 
 
+@pytest.mark.asyncio
+async def test_over_limit_still_rejects_when_prior_scope_rollback_fails():
+    key_scope = _scope(limit=100, key="api_key")
+    team_scope = _scope(limit=50, key="team")
+    fake = _SingleKeyRedisFake(fail_refund_keys=frozenset({f"batch_enqueued_tokens:api_key:{key_scope.value}"}))
+    store = BatchEnqueuedTokenStore(
+        internal_usage_cache=InternalUsageCache(DualCache(redis_cache=fake, default_in_memory_ttl=60))
+    )
+    seed = await store.reserve(tokens=30, scopes=(key_scope, team_scope))
+    assert isinstance(seed, BatchEnqueuedTokenReservation)
+
+    outcome = await store.reserve(tokens=25, scopes=(key_scope, team_scope))
+    assert outcome == BatchEnqueuedTokenOverLimit(scope=team_scope, enqueued=30)
+
+
+@pytest.mark.asyncio
+async def test_pop_reservation_falls_back_to_local_when_save_wrote_locally():
+    key_scope = _scope(limit=100, key="api_key")
+    record_key: Final = "batch_enqueued_token_reservation:batch_local"
+    fake = _SingleKeyRedisFake(fail_save_keys=frozenset({record_key}))
+    store = BatchEnqueuedTokenStore(
+        internal_usage_cache=InternalUsageCache(DualCache(redis_cache=fake, default_in_memory_ttl=60))
+    )
+    reservation = BatchEnqueuedTokenReservation(tokens=40, scopes=(key_scope,), backend="redis")
+
+    await store.save_reservation("batch_local", reservation)
+    assert record_key not in fake.records
+
+    popped = await store.pop_reservation("batch_local")
+    assert popped == reservation
+    assert await store.pop_reservation("batch_local") is None
+
+
 @pytest.mark.asyncio
 async def test_pop_reservation_defaults_legacy_records_to_redis_backend():
     store = _in_memory_store()

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

Comment thread litellm/proxy/hooks/batch_enqueued_tokens.py
Comment thread litellm/proxy/hooks/batch_enqueued_tokens.py
…ailure, find locally saved records on pop

A Redis over-limit verdict now survives a failing rollback DECRBY instead of
escaping into the in-memory fallback and granting tokens the counter already
rejected; the unrolled increments expire with the TTL. pop_reservation now
falls through to the local record when the Redis pop succeeds but finds
nothing, so a reservation saved in memory after a transient Redis save
failure still refunds on cancel or completion.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

…oxy admins

The field replaces the standard RPM/TPM checks for batch submissions, so a
key holder or team admin writing it could pick their own batch quota.
Mirrors the output-token-estimate admin gate: change-based, so resending
the stored value stays allowed, and enforced on key generate, update, bulk
team-key update, regenerate, and team new/update.
@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 all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ mateo-berri
❌ hiraku-miyoshi


hiraku-miyoshi seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@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: Local fallback can double-refund quota
    • Eliminated the dual-write at its source: save_reservation now returns without writing a local copy when the Redis save raises, since a Redis SET can still land server-side after the client times out and a local fallback would let a later pop refund again.

Create PR

Or push these changes by commenting:

@cursor push 2b0b853a67
Preview (2b0b853a67)
diff --git a/litellm/proxy/hooks/batch_enqueued_tokens.py b/litellm/proxy/hooks/batch_enqueued_tokens.py
--- a/litellm/proxy/hooks/batch_enqueued_tokens.py
+++ b/litellm/proxy/hooks/batch_enqueued_tokens.py
@@ -355,12 +355,11 @@ async def save_reservation(
                     (self._record_key(batch_id),),
                     (serialized, BATCH_ENQUEUED_TOKEN_TTL_SECONDS),
                 )
-            except Exception as e:  # noqa: BLE001  # any Redis failure must fall back to the in-memory record
+            except Exception as e:  # noqa: BLE001  # a Redis SET can land server-side after the client raises; a local fallback would risk a double refund via pop_reservation
                 verbose_proxy_logger.warning(
-                    "Redis enqueued-token reservation save failed, falling back to in-memory: %s", str(e)
+                    "Redis enqueued-token reservation save failed; refund may leak until TTL: %s", str(e)
                 )
-            else:
-                return
+            return
         await self.internal_usage_cache.async_set_cache(
             key=self._record_key(batch_id),
             value=serialized,

@@ -355,12 +355,11 @@ async def save_reservation(
                     (self._record_key(batch_id),),
                     (serialized, BATCH_ENQUEUED_TOKEN_TTL_SECONDS),
                 )
-            except Exception as e:  # noqa: BLE001  # any Redis failure must fall back to the in-memory record
+            except Exception as e:  # noqa: BLE001  # a Redis SET can land server-side after the client raises; a local fallback would risk a double refund via pop_reservation
                 verbose_proxy_logger.warning(
-                    "Redis enqueued-token reservation save failed, falling back to in-memory: %s", str(e)
+                    "Redis enqueued-token reservation save failed; refund may leak until TTL: %s", str(e)
                 )
-            else:
-                return
+            return
         await self.internal_usage_cache.async_set_cache(
             key=self._record_key(batch_id),
             value=serialized,

diff --git a/tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py b/tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py
--- a/tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py
+++ b/tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py
@@ -246,7 +246,10 @@ async def test_over_limit_verdict_survives_a_failing_rollback():
 
 
 @pytest.mark.asyncio
-async def test_pop_falls_back_to_local_record_when_redis_pop_finds_nothing():
+async def test_save_reservation_does_not_dual_write_when_redis_save_raises():
+    """LIT-5273 regression: a Redis SET timeout can still land server-side, so writing a local
+    fallback on save failure risks a double refund the first time pop_reservation runs against
+    Redis and a later cancel/retrieve pops the leftover local record."""
     scope = _scope(limit=100)
     record_key: Final = "batch_enqueued_token_reservation:batch_local_record"
     fake = _SingleKeyRedisFake(fail_save_keys=frozenset({record_key}))

@@ -246,7 +246,10 @@ async def test_over_limit_verdict_survives_a_failing_rollback():
 
 
 @pytest.mark.asyncio
-async def test_pop_falls_back_to_local_record_when_redis_pop_finds_nothing():
+async def test_save_reservation_does_not_dual_write_when_redis_save_raises():
+    """LIT-5273 regression: a Redis SET timeout can still land server-side, so writing a local
+    fallback on save failure risks a double refund the first time pop_reservation runs against
+    Redis and a later cancel/retrieve pops the leftover local record."""
     scope = _scope(limit=100)
     record_key: Final = "batch_enqueued_token_reservation:batch_local_record"
     fake = _SingleKeyRedisFake(fail_save_keys=frozenset({record_key}))
@@ -258,11 +261,9 @@ async def test_pop_falls_back_to_local_record_when_redis_pop_finds_nothing():
     assert isinstance(reservation, BatchEnqueuedTokenReservation)
     await store.save_reservation("batch_local_record", reservation)
     assert not fake.records
-
-    popped = await store.pop_reservation("batch_local_record")
-    assert popped == reservation
-    await store.refund(popped)
-    assert not fake.counters
+    assert (
+        store.internal_usage_cache.dual_cache.in_memory_cache.get_cache(key=record_key) is None
+    )
     assert await store.pop_reservation("batch_local_record") is None
 
 

@@ -258,11 +261,9 @@ async def test_pop_falls_back_to_local_record_when_redis_pop_finds_nothing():
     assert isinstance(reservation, BatchEnqueuedTokenReservation)
     await store.save_reservation("batch_local_record", reservation)
     assert not fake.records
-
-    popped = await store.pop_reservation("batch_local_record")
-    assert popped == reservation
-    await store.refund(popped)
-    assert not fake.counters
+    assert (
+        store.internal_usage_cache.dual_cache.in_memory_cache.get_cache(key=record_key) is None
+    )
     assert await store.pop_reservation("batch_local_record") is None

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

Comment thread litellm/proxy/hooks/batch_enqueued_tokens.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/hooks/batch_enqueued_tokens.py Outdated
@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 5ab20c3. Configure here.

@codspeed-hq

codspeed-hq Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_batch_enqueued_token_limit (d5ac495) with litellm_internal_staging (c2b3c4b)

Open in CodSpeed

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

)
stash.parallel_slot = None

if stash.batch_enqueued_reservation is not None:

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: Provider-side timeouts refund accepted batches

This refunds the reservation for every failure, although the batch endpoint also invokes this hook for exceptions after provider dispatch. Because the batch request accepts a caller-controlled timeout, a user can tune repeated requests to time out after the provider accepts them, restoring the allowance while those upstream batches continue processing. Only refund when the request was never dispatched; for an ambiguous provider outcome, retain the reservation until the batch is reconciled or its TTL expires.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ambiguous timeouts leave no batch id to reconcile against; retaining would lock the allowance 8 days on every transient failure. Documented as bounded residual risk.

@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 2a771ca. Configure here.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/hooks/batch_enqueued_tokens.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 d5ac495. Configure here.

@mateo-berri
mateo-berri enabled auto-merge August 20, 2026 04:11
@mateo-berri
mateo-berri merged commit 47a7e17 into litellm_internal_staging Aug 20, 2026
78 of 79 checks passed
@mateo-berri
mateo-berri deleted the litellm_batch_enqueued_token_limit branch August 20, 2026 05:22
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.

3 participants