Skip to content

[Bug]: fail_closed_budget_enforcement does not reject failed atomic budget reservations #33923

Description

@emerzon

Check for existing issues

  • I have searched the existing issues and checked that my issue is not a duplicate

What happened?

With general_settings.fail_closed_budget_enforcement set to true, a configured budget still fails open when its atomic pre-call reservation cannot be written

The strict setting is consulted by get_current_spend and returns 503 when the current spend cannot be verified against Redis or the database

def _fail_closed_budget_enforcement() -> bool:
return general_settings.get("fail_closed_budget_enforcement") is True
def _raise_budget_unverifiable(counter_key: str) -> None:
verbose_proxy_logger.warning(
"fail_closed_budget_enforcement: rejecting request — spend for %s could "
"not be verified against Redis or the database",
counter_key,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"error": (
"Budget enforcement unavailable: current spend could not be "
"verified against Redis or the database, and "
"fail_closed_budget_enforcement is enabled, so the request was "
"rejected to avoid exceeding the configured budget. Retry shortly."
)
},
)

# Opt-in hard guarantee: when the spend backing this admit decision came
# only from a per-pod cache (Redis and DB both unreadable), reject rather
# than admit on an unverifiable budget. No-op unless the flag is set, so
# default behavior is unchanged.
if not verified and _fail_closed_budget_enforcement():
_raise_budget_unverifiable(counter_key)

The atomic reservation runs later, after the read-time common checks. reserve_budget_for_request catches _CounterReservationUnavailable for each required counter, removes that counter from the reservation, and continues. If every counter fails, it returns None and the request proceeds without an atomic admission hold

await _reserve_budget_after_common_checks(
user_api_key_auth_obj=user_api_key_auth_obj,
request_data=request_data,
route=route,
llm_router=llm_router,
team_object=team_object,
user_object=user_object,
end_user_id=end_user_id,
end_user_object=end_user_object,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
skip_budget_checks=skip_budget_checks,
general_settings=general_settings,
)

try:
for counter in counters:
entry = _counter_to_reservation_entry(
counter=counter,
reserved_cost=reservation_cost,
)
applied_entries.append(entry)
try:
reserved_value = await _reserve_counter(
counter=counter,
reservation_cost=reservation_cost,
)
except _CounterReservationUnavailable as exc:
if exc.touched_counter and not exc.counter_invalidated:
await _release_applied_entries_best_effort(
entries=[entry],
default_reserved_cost=reservation_cost,
)
applied_entries.remove(entry)
continue
if reserved_value is not None:
current_spend = reserved_value
else:
cached_spend = current_spend_by_counter_key.get(counter.counter_key)
if cached_spend is None:
cached_spend = await _get_current_counter_value(counter=counter)
current_spend = cached_spend + reservation_cost
if current_spend > counter.max_budget:
reservation_cost = await _apply_over_budget_reservation_policy(
counter=counter,
valid_token=valid_token,
entry=entry,
applied_entries=applied_entries,
reservation_cost=reservation_cost,
current_spend=current_spend,
)
continue
except Exception:
await _release_applied_entries_best_effort(
entries=applied_entries,
default_reserved_cost=reservation_cost,
)
raise
if not applied_entries:
return None

A verified read does not replace the reservation. Concurrent requests can all verify the same under-budget value, then all lose their reservation writes during a Redis timeout or failover, and all reach the provider before any completed cost is recorded

The warning used when reservation is explicitly disabled describes this read-time-only mode as allowing concurrent overspend and calls reservation hard per-request budget enforcement

if general_settings.get("disable_budget_reservation") is True:
verbose_proxy_logger.warning(
"disable_budget_reservation is enabled: skipping optimistic budget "
"reservation. Budget enforcement is read-time only — concurrent "
"requests can each pass the spend check before their cost is recorded, "
"so a configured budget may be briefly exceeded under high concurrency. "
"Set disable_budget_reservation to False or remove it to restore "
"hard per-request budget enforcement."
)

The default availability-first behavior can remain unchanged if intentional. When fail_closed_budget_enforcement is true, a reservation infrastructure failure for any configured budget counter should reject the request with 503 instead of silently degrading to read-time-only enforcement

This is separate from #33323, which covers exception swallowing in the legacy max_budget_limiter hook. This issue is in the central atomic reservation path after common_checks

Steps to Reproduce

  1. Configure a virtual key with max_budget and set general_settings.fail_closed_budget_enforcement to true
  2. Let the read-time budget check return a verified value below max_budget
  3. Make the reservation counter increment or initialization raise, for example by simulating a Redis timeout
  4. Call reserve_budget_for_request with a positive estimated request cost
  5. Observe that the function logs a warning and returns None instead of raising 503
  6. Run several requests concurrently and observe that each can proceed without a shared reservation

The existing regression test already locks in the fail-open result by forcing the counter increment to raise and asserting reservation is None

async def test_should_skip_reservation_when_counter_increment_fails(
spend_counter_state,
monkeypatch,
):
counter_cache, key_cache = spend_counter_state
proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
valid_token = UserAPIKeyAuth(
token="key-budget-reserve-unavailable",
spend=0.0,
max_budget=1.0,
)
async def fail_increment_cache(*args, **kwargs):
raise RuntimeError("counter unavailable")
monkeypatch.setattr(counter_cache, "async_increment_cache", fail_increment_cache)
with (
patch(
"litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
return_value=0.5,
),
patch(
"litellm.proxy.spend_tracking.budget_reservation.verbose_proxy_logger.warning"
) as mock_warning,
):
reservation = await reserve_budget_for_request(
request_body=_request_body(),
route="/chat/completions",
llm_router=None,
valid_token=valid_token,
team_object=None,
user_object=None,
prisma_client=None,
user_api_key_cache=key_cache,
proxy_logging_obj=proxy_logging_obj,
)
assert reservation is None
assert mock_warning.call_count >= 1
assert (
counter_cache.in_memory_cache.get_cache(

A strict-mode regression should run the same failure with general_settings.fail_closed_budget_enforcement true and assert HTTP 503. A companion test can preserve the current default behavior when the setting is absent or false

Relevant log output

Skipping budget reservation for spend:key:<key> because spend counter reservation failed

The request continues with budget_reservation set to None

What part of LiteLLM is this about?

Proxy

What LiteLLM version are you on ?

v1.94.0, current litellm_internal_staging at 3f9b71c. The affected files are unchanged in fetched upstream main and litellm_internal_staging as of 2026-07-19

Twitter / LinkedIn details

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions