Get your API key →

How preflight avoids double-billing under concurrent load

May 2026 · 6 min read

A developer on Reddit asked a sharp question about AgentBill's checkpoint pattern: "Most checkpoint patterns I've seen either re-meter or skip metering and lose accuracy. How does the read-only check stay consistent with the final settlement?"

It's the right question. The naive implementation of a preflight check has a race condition that causes exactly this problem. Here's how AgentBill solves it.

The problem: read-check-approve is broken under concurrency

The obvious implementation of a preflight check looks like this:

# Naive implementation, DO NOT use in production
def preflight(customer_id, estimated_units):
    customer = db.query("SELECT used_units, limit_units FROM customers WHERE id = ?", customer_id)
    remaining = customer.limit_units - customer.used_units

    if estimated_units > remaining:
        return {"approved": False}

    return {"approved": True}
  

This reads the current balance, checks if the run fits, and returns a decision. Under a single serial workload it works fine.

Under concurrent load it breaks. Consider two agent runs starting at the same millisecond for the same customer who has 10 units remaining, each estimating 8 units:

Thread A: reads remaining = 10. 8 <= 10. Approved.
Thread B: reads remaining = 10. 8 <= 10. Approved.

Thread A runs. Uses 8 units. Used = 8.
Thread B runs. Uses 8 units. Used = 16. Limit exceeded.
  

Both reads happen before either write. Both see the same balance. Both get approved. The customer burns 16 units against a 10-unit budget. The check was useless.

This is a classic TOCTOU race: Time Of Check, Time Of Use. The check and the use happen at different times, and the state can change between them.

The fix: atomic reservation

AgentBill doesn't just read the balance, it reserves units atomically inside a transaction. The preflight UPDATE only succeeds when there's enough budget remaining:

-- This is what happens inside AgentBill's preflight
UPDATE customers
SET reserved_units = reserved_units + :estimated_units
WHERE account_id = :account_id
  AND customer_ref = :customer_ref
  AND (
    limit_units IS NULL
    OR used_units + reserved_units + :estimated_units <= limit_units
  )
RETURNING limit_units, used_units, reserved_units
  

If budget is available, the UPDATE succeeds and returns the updated row. The reservation is now reflected in reserved_units, visible to every subsequent transaction.

If budget is exhausted, the WHERE clause matches 0 rows. The UPDATE returns nothing. The run is blocked. No budget was consumed.

Replaying the concurrent scenario:

Thread A: UPDATE adds 8 to reserved_units. reserved = 8. Succeeds.
Thread B: UPDATE tries to add 8. used + reserved + 8 = 16 > 10. WHERE fails. Blocked.

Thread A runs. Completes. record() converts reserved → used.
  

The database handles the serialization. No application-level locking required.

Settlement: converting reserved to used

After the agent run completes, record() settles the reservation:

UPDATE customers
SET used_units     = used_units + :actual_units,
    reserved_units = reserved_units - :estimated_units
WHERE account_id = :account_id
  AND customer_ref = :customer_ref
  

The reserved units come out. The actual units go in. The net balance reflects reality.

If actual_units differs from estimated_units, say you estimated 10 but the run used 7, the difference is released back into available budget. No manual adjustment needed.

What happens when a run fails

A reservation is released in exactly one place: record(). Call it with success=false and the reserved units go back without billing anything.

# The run failed. Release the reservation, bill nothing.
# units must match what preflight reserved.
client.record(agent_id="researcher", units=200, success=False)
  

The SDK decorator does this for you: it wraps the call in try/except and releases on the way out of a failed run.

If record() never arrives at all, the units stay reserved until they expire. Each reservation carries a TTL, returned to the caller as reservation_expires_at on every approved preflight, and a sweeper reclaims the ones that pass it.

Getting that sweeper right needed one change to the shape of the data. reserved_units is a counter, and a counter cannot be swept, because it does not know how much of itself is stale. So a reservation is a row, and the counter is the sum of the open rows:

-- The invariant every path maintains
customers.reserved_units    = SUM(units) of open rows for that customer
task_budgets.reserved_units = SUM(units) of open rows for that task
  

Which turns the sweep into something boring, and boring is the goal on this path:

-- Claim expired rows and release their units, in ONE transaction.
-- SKIP LOCKED because production runs more than one machine.
UPDATE reservations SET released_at = now()
WHERE id IN (
  SELECT id FROM reservations
  WHERE released_at IS NULL AND expires_at < now()
  ORDER BY expires_at LIMIT 500
  FOR UPDATE SKIP LOCKED
)
RETURNING customer_id, task_ref, units
  

The bug this design exists to prevent

Now that two different things can release the same reservation, the sweeper and a late record(), the obvious implementation is wrong in the dangerous direction.

Consider a run that dies, gets swept an hour later, and then, somehow, settles: a queued retry, a delayed worker, a caller that kept the id. If record() decrements reserved_units by its units argument, those units come off twice, once from the sweeper and once from the settle. The counter now sits below the units genuinely in flight, and the gate starts approving runs against budget that another run is already holding. A double release is a double spend.

So the settle path does not decrement by what the caller sent. It closes reservation rows FIFO, counts what those rows were actually holding, and decrements by that:

# units always moves: the spend really happened.
# reserved moves by what the closed rows held, which is 0
# if the sweeper already reclaimed them.
consumed = consume_reservations(customer_id, task_ref, units)

UPDATE customers
SET used_units     = used_units + :units,
    reserved_units = GREATEST(0, reserved_units - :consumed)
  

A settle for a reservation that no longer exists finds nothing to close, gets consumed = 0, and leaves the counter alone. Same code path covers record() calls that never had a preflight at all.

The retry that reserved twice

One more hole worth naming, because it was in the mechanism meant to prevent waste. /events has enforced (account_id, idempotency_key) UNIQUE since the beginning. /preflight had nothing, so a client that retried a timed-out preflight reserved a second time, and an aggressive retry policy could exhaust a budget without a single model call behind it.

preflight now takes the same idempotency_key. The key is claimed inside the reserving transaction, so a duplicate blocks on the unique index rather than racing: same key, same decision, one reservation. A retry that lands while the original is still being decided gets 409 preflight_in_progress, which is not a block and reserves nothing.

Note which way all of this fails. An abandoned reservation makes the ceiling tighter, never looser: the run that gets blocked is a later one, not an expensive one that should have been stopped. Every correctness choice above preserves that direction. The gate does not open by accident.

Why this matters for metering accuracy

The developer's question was specifically about consistency between the check and the settlement. The reservation pattern guarantees this in three ways:

1. No double-approval. The atomic UPDATE ensures only one concurrent run can claim a given unit of budget. The database is the lock.

2. No phantom budget. Every approved run immediately reduces the available budget visible to subsequent runs. There's no window where the same units appear available twice.

3. Accurate settlement. The record() call replaces estimated with actual. The reservation was a claim, not a charge. The charge happens at settlement with the real number.

The full flow

preflight(estimated_units=10)
  → atomic UPDATE reserves 10 units
  → returns approved=true, remaining_units=N

agent runs (actual cost: 7 units)

record(units=7)
  → used_units += 7
  → reserved_units -= 10
  → net: 7 charged, 3 released
  

If two runs start simultaneously, only one can atomically claim the budget. The other is blocked at the database level before any compute runs.

Add preflight to your agents

Free tier: 1,000 preflight calls/month. No credit card required.

Get your API key