fix(proxy): retain known spend when no-DB reconcile fails

When BudgetReservationReconcileLogger cannot settle a reservation and
response_cost is positive, do not release_or_invalidate (which refunds
to 0.0 and finalizes). Without Prisma there is no ledger to restore
that spend; leave the reservation open so settlement can retry and
budgets fail closed. Zero-cost failures still release as before.
This commit is contained in:
er-s-an 2026-11-04 20:18:28 +08:00
parent ccdb2b76b8
commit e6bbb744c2
2 changed files with 41 additions and 1 deletions

View file

@ -63,7 +63,17 @@ class BudgetReservationReconcileLogger(CustomLogger):
budget_reservation=budget_reservation,
actual_cost=actual_cost,
)
except Exception: # noqa: BLE001 # a counter backend failure must still release the reservation
except Exception: # noqa: BLE001 # counter failure: refund only when no known spend
if actual_cost > 0:
# No Prisma ledger on this path. Releasing to 0.0 / finalizing would
# permanently refund completed work and block retry; leave the
# reservation open so settlement can retry and budgets fail closed.
verbose_proxy_logger.exception(
"Failed to reconcile budget reservation without a database logger; "
"retaining known spend %s for retry",
actual_cost,
)
return
verbose_proxy_logger.exception(
"Failed to reconcile budget reservation without a database logger; releasing instead"
)

View file

@ -151,6 +151,36 @@ async def test_success_falls_back_to_release_when_reconcile_fails(
release.assert_awaited_once_with(budget_reservation=reservation)
@pytest.mark.asyncio
async def test_success_retains_known_spend_when_reconcile_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
reservation = _reservation()
monkeypatch.setattr(
"litellm.proxy.spend_tracking.budget_reservation.reconcile_budget_reservation",
AsyncMock(side_effect=RuntimeError("counter down")),
)
release = AsyncMock()
monkeypatch.setattr(
"litellm.proxy.spend_tracking.budget_reservation.release_or_invalidate_budget_reservation",
release,
)
logger = BudgetReservationReconcileLogger()
await logger.async_log_success_event(
{
"litellm_params": {"metadata": {"user_api_key_budget_reservation": reservation}},
"response_cost": 0.004,
},
response_obj=object(),
start_time=datetime.now(UTC),
end_time=datetime.now(UTC),
)
release.assert_not_awaited()
assert reservation["finalized"] is False
@pytest.fixture
def spend_counter_cache(monkeypatch: pytest.MonkeyPatch) -> DualCache:
cache: Final = DualCache()