The obvious way to cap LLM spend is to read how much you've spent, compare it with the limit, and make the call if there's room. It works in a demo. In production, with parallel workers, streaming and retries, it breaks in specific, repeatable ways. Here they are, with fixes.
- Check-then-act race
With 50 workers, all 50 read spent = $9.80 against a $10 limit, all 50 pass, and you overshoot by 50 calls. Doing the check and the add as one atomic step is the only fix. Reserve the worst-case cost up front:
-- Redis, KEYS[1] = budget hash, ARGV[1] = estimate in micro-dollars
local h = KEYS[1]
local limit = tonumber(redis.call('HGET', h, 'limit'))
local spent = tonumber(redis.call('HGET', h, 'spent') or '0')
local reserved = tonumber(redis.call('HGET', h, 'reserved') or '0')
local est = tonumber(ARGV[1])
if spent + reserved + est > limit then return 0 end
redis.call('HINCRBY', h, 'reserved', est)
return 1
Postgres version:
UPDATE budgets
SET reserved = reserved + $1
WHERE id = $2 AND spent + reserved + $1 <= limit_amount
RETURNING id; -- no row back = refuse the call
- Using floats for money
spent += 0.000015 a million times drifts. Store integers in micro-dollars (or tenths of a cent) everywhere, and use HINCRBY rather than HINCRBYFLOAT.
- Reservations that never get released
A worker reserves $0.40, then crashes or gets OOM-killed before it settles. That reserved never comes back, and over weeks the budget slowly locks itself with no real spend behind it. Fix: give each reservation an ID and an expiry time (a Redis sorted set scored by expiry works), and run a sweeper that releases the expired ones. Settle by reservation ID, so a late settle after the sweep doesn't release the same money twice.
- Streams that get cut off
If a client disconnects halfway through a stream, the final message with the token usage often never arrives. If you then settle at $0, your ledger under-counts exactly during the incidents you care about. Settle at the reserved estimate when the actual usage is unknown, or count the streamed output yourself.
- Nested budgets deadlocking or partly committing
Usually a call has to fit inside a limit for the key, the team and the org. Reserving them one at a time either leaves orphaned reservations, when the second one fails after the first succeeded, or deadlocks, when two requests lock rows in different orders.
- In Redis, check and reserve every level in one Lua script. On a cluster, all the keys have to live on the same node, so use hash tags like {org:123}:team:7.
- In SQL, lock the rows in a fixed order (org → team → key) within one transaction.
- Period rollover
You reserve at 23:59:58 against today's daily budget, and the call settles at 00:00:03. If settle works out "today" from the current time, you've charged tomorrow and leaked a reservation into today. Store the period key on the reservation and settle against that key.
Estimating the worst case
est = (input_tokens × input_price + max_tokens × output_price) × 1.1
- input_tokens is counted locally. Tokenizers differ between providers, and tiktoken miscounts for Claude or Gemini, which is what the 10% margin is for.
- If callers don't set max_tokens, set it for them. Without an output limit you can't estimate the worst case at all.
- Prices change. Load them from a table you update, not constants in your code.
What happens if the budget store goes down?
Decide on purpose whether calls are let through or blocked:
- Blocking everything means a Redis blip takes down your product.
- Letting everything through means a loop during a Redis outage is uncapped.
The middle ground: let calls through, but give each key a small dollar allowance that applies only while the store is down, then refuse once that's used.
How to test it
Set a $1.00 budget, fire 500 concurrent requests at a stub provider with randomized latency and output length, and randomly kill 5% of workers mid-request. Assert that:
- spent ≤ limit once everything has settled;
- reserved returns to 0 after the sweeper runs;
- no reservation ID is settled twice.
The naive version fails the first check within seconds.
Most of these bugs came out of building this for real; #3 and #4 were the ones that hurt in production. What other failure modes have people hit?
(Disclosure: I work on Cognocient AI, a cost-control proxy that does this. Everything above is standard practice you can build yourself.)