Reset and Top-Up¶
Two administrative operations change an entity's balance on one resource immediately, on every shard, without touching its configuration:
| Operation | What it does | Typical use |
|---|---|---|
Repository.reset_bucket() |
Restores each limit to its full share and starts a new period | Support gives a user their allowance back; a session is reset |
Repository.top_up() |
Adds tokens now | A purchase of more allowance; the difference after a plan upgrade |
from datetime import timedelta
from zae_limiter import Limit, Repository
repo = await Repository.open()
await repo.set_limits(
"user-123",
[Limit.per_minute("rpm", 100), Limit.quota("session", 10_000, reset_after=timedelta(hours=5))],
resource="claude",
)
# Everything back to full for this entity on this resource
await repo.reset_bucket("user-123", "claude")
# Only the session quota
await repo.reset_bucket("user-123", "claude", limits=["session"])
# 5,000 more session tokens, this period only
result = await repo.top_up("user-123", "claude", {"session": 5_000})
print(result.amounts) # {'session': 5000}
The CLI equivalents are zae-limiter entity reset and zae-limiter entity top-up (see the
CLI reference).
What each operation does, per limit shape¶
| Limit shape | reset_bucket() |
top_up({name: N}) |
|---|---|---|
Dripping (Limit.per_minute, …) |
Back to its ceiling; any debt is forgiven | At most the room below its ceiling — a refund, not a purchase. result.amounts says how much was granted |
Scheduled quota (Limit.quota(..., cron=...)) |
Back to its share; a new period starts now, and the next calendar reset still fires as usual | Exactly N for the current period, above the plan if need be. The next reset ends it |
Session quota (Limit.quota(..., reset_after=...)) |
Its current window ends; the next request opens a fresh one at its full share (the balance is left for that request to restore) | Exactly N until the window ends. With no live window, the top-up opens one now: the purchase starts the session |
amounts mirrors acquire(consume=...): limit name to whole tokens, and several limits can be
topped up in one call. A pending reset — a quota whose calendar edge or window end has passed
since it was last used — is applied first, in the same write, so the tokens always land in the
current period.
Neither operation touches the consumption counter (usage snapshots stay right), the disabled
flag, configuration, the entity's parent, or other resources. A cascading child's reset leaves
its parent's buckets alone; reset the parent too if it is the one exhausted.
Plan upgrades and purchases¶
A purchase is top_up() alone. The tokens sit above the plan until the next reset or window
end; nothing persists into later periods.
A plan upgrade is two calls: write the new plan, then top up the difference so the change is felt now rather than at the next reset (a quota never drips, so without the top-up the user keeps the old remaining balance until then):
await repo.set_limits(
"user-123", [Limit.quota("daily", 25_000, cron="0 0 * * *")], resource="claude"
)
await repo.top_up("user-123", "claude", {"daily": 25_000 - 10_000})
The gap between the two calls can only under-admit. The top-up still records the whole
difference as topped up (b_{name}_tu), so the upgrade needs the 0.17.0 Lambdas below, and
until the next reset the quota's ceiling is up to one top-up wider than the new plan. That only
lets refunds of tokens already spent land; it never grants extra.
Retries: pass an idempotency key¶
Without a key, an error from reset_bucket() or top_up() leaves the outcome unknown: a
timeout can arrive after the write landed, and retrying may grant twice. Name the operation
with something unique to it — a purchase or ticket id — and retry with the same key:
await repo.set_limits(
"user-123", [Limit.quota("daily", 10_000, cron="0 0 * * *")], resource="claude"
)
result = await repo.top_up("user-123", "claude", {"daily": 5_000}, idempotency_key="order-8841")
# A retry within three days returns the same result and grants nothing again.
again = await repo.top_up("user-123", "claude", {"daily": 5_000}, idempotency_key="order-8841")
assert again == result
A key is 1-64 characters of letters, digits, ., _, : and -, and names one kind of
operation: reusing a reset's key for a top-up is a ValidationError. With a key, each call
reads its record first (+1 RCU).
A keyed call that changes nothing is recorded too: a reset of an entity with no bucket yet, or a top-up of dripping limits only. A retry with the same key returns that first result (0) even after the bucket exists, and grants nothing.
A key belongs to one entity, and to one incarnation of it: if the entity is deleted and created again, the same key names a new operation. Two cases are not covered:
| Case | What happens |
|---|---|
| The entity is deleted and recreated while a call is in flight | A retry with the same key can be applied once more |
A call made while the entity has no entity record, or a key first used then (for example, right after delete_entity, or before create_entity) |
It can return the earlier result and apply nothing, even after the entity is created again |
The second applies too little, never too much. To be safe, use a fresh key after deleting an entity, and once the entity exists.
Requirements and costs¶
- A quota top-up needs Lambdas at 0.17.0 or later. An older aggregator would clamp the
topped-up shard back to its plan and the purchase would be lost.
top_up()checks the stack's version record once per repository and raisesVersionMismatchErrorwhen it is older; it also raises the stack's minimum client version to 0.17.0. Runzae-limiter upgradefirst. A reset, and a top-up of a dripping limit, are not gated. - Cost: about 3 RCU + 6 WCU at one shard (one transaction holding the shard write, the operation's own record and its audit event), growing by about 1 RCU + 2 WCU per extra shard, plus one slow pass per shard on its next acquire. A keyed call that changes nothing costs 4 WCU (its record and audit event); without a key it writes nothing. The acquire fast path is unchanged.
- Another process may refuse for up to its rejection-cache TTL (default 1 s) after a reset or top-up made elsewhere. The process that made the change admits at once.
Who may reset or top up¶
Reset and top-up are operator actions: call them from a principal holding the full or
ns-full policy (or your own equivalent), never from the application that is being limited.
IAM cannot stop an application from granting itself allowance
A principal that may acquire() must be able to update its own bucket items, so it can also
raise their balance, or a quota's top-up, with a direct DynamoDB write, and the stack's
acquire policies (acq, ns-acq) do not refuse reset_bucket() or top_up() either. An
application enforces its own limits; it can also simply not call acquire(). When paid
allowance must hold even against the application, the application must hold no DynamoDB
write credentials: run acquire() in a service it can only invoke, and top-ups in an
operator-only one.
Edge cases¶
- A reset of an entity that has never acquired on the resource is a no-op (
result.shards == 0): its first acquire creates the bucket at full share anyway. - A quota top-up for an entity that has never acquired creates its bucket holding the full
share plus the top-up. If the resource is disabled for the entity, it raises
ResourceDisabledinstead. - A limit configured after the entity's bucket existed, and not yet used since, is not on
the bucket yet: a top-up of it raises a
ValidationErrornaming it, and writes nothing. Acquire once, then top up. - If another writer keeps changing the bucket, the operation retries three times and then
raises
RateLimiterUnavailable. The reset or top-up itself was not applied. One write can remain: when the bucket's shards disagree on their shard count, the lagging shards are first raised to the highest count (bookkeeping that moves no tokens), and that raise is kept. - A write whose response is lost is never applied twice: each call writes its own operation record in the same transaction, and a retry that finds it returns the stored result instead of re-applying — even when another reset or top-up landed on the bucket in between.
- Every operation that writes has exactly one audit event: it is written in the same
transaction, so it lands exactly when the operation does, however the call ends. An invalid
principalis refused before anything is written. - On a bucket using resource or system defaults, which expires when idle, a reset or top-up also extends the bucket's expiry to cover at least the current period, so a purchase is not swept away before it can be spent. A bucket with entity-level limits never expires.
See ADR-149 for the design.