Session Quotas¶
A session quota is an allowance that comes back a fixed time after each caller's own first use — "10,000 tokens, and your window resets five hours after you started" — rather than at the same wall-clock instant for everyone.
from datetime import timedelta
from zae_limiter import Limit
session = Limit.quota("session", 10_000, reset_after=timedelta(hours=5))
Two callers that first use it at 09:00 and 14:30 get windows ending at 14:00 and 19:30.
Upgrade every client and admin tool before relying on one; the Lambdas are checked for you
reset_after is new in v0.15.0.
The aggregator is enforced. set_limits(), set_resource_defaults(),
set_system_defaults(), zae-limiter limits apply and an acquire(limits=[...])
override refuse a reset_after limit with VersionMismatchError until the stack's version
record says its Lambdas are v0.15.0 or newer (a 0.15.0 release candidate counts). An older
aggregator would treat the quota as a dripping limit and grant each shard it pre-creates a
fresh share. Repository.open() updates old Lambdas for you; after Repository.connect()
or auto_update=False, run zae-limiter upgrade first — it works on a stack deployed with
--no-aggregator, --no-provisioner or --no-iam too, pushing code only to the Lambdas
the stack has. deploy records the new version only if the stack is new, or both
the aggregator and the provisioner are current (code pushed in that run, or absent) — it
pushes code to an existing stack's functions but never adds or removes one, so an old
function left in place keeps the stack refused. The
config writers pay one strongly consistent read per write that carries reset_after; the
acquire() override pays nothing, trusting the version read when the repository was opened.
A stack whose Lambda version is unknown is refused too. When Repository.open() finds
a table with no version record — say, one built from an older cfn-template — it writes
one without claiming an aggregator it did not deploy (it claims its own version only when it
finds no aggregator at all). Run zae-limiter upgrade to deploy the Lambdas and stamp the
version.
Clients older than v0.15.0 are not enforced. A successful write raises the stack's
client_min_version to 0.15.0, and every client from v0.15.0 on refuses to start below it
— but v0.14 ignores that field. A reset_after limit is stored where a v0.14 client does
not look, so a v0.14 client does not see the session limit at all: it keeps enforcing
every other limit on that level, under either on_unavailable setting, and simply does not
enforce the session limit for the requests it serves. If the level holds nothing but the
session limit, a v0.14 client falls through to the next level (resource, then system
defaults) and enforces that instead. With on_unavailable="block" that is a deliberate
trade: without it the same client would fail every acquire() on the level — an outage —
and with "allow" it would admit them with no limiting at all.
Never run a v0.14 admin tool against a level that holds one. A v0.14 set_limits(),
delete_limits(), set_resource_defaults() or CLI set-* command rewrites
the whole level from the limits it can see, so it silently drops the session limit
from config, and leaves the session balance behind on the buckets. Upgrade admin tooling
first.
Never run a v0.14 CLI against a stack that holds one. The check runs only at write time, and v0.14 can undo it afterwards:
zae-limiter deployorupgrade --forcefrom v0.14 puts the v0.14 Lambdas back and stampslambda_version = 0.14.0.- A v0.14
upgradetreats the raised minimum as "not up to date", so it too downgrades the Lambdas — and resetsclient_min_versionto0.0.0.
The next v0.15 Repository.open() re-upgrades the Lambdas; the minimum comes back only
with the next reset_after write. A long-lived process that opened its repository before
a downgrade keeps trusting the version it read for acquire(limits=...) overrides.
A refused CloudFormation update can leave the stack in UPDATE_ROLLBACK_FAILED when
the previous properties also carried a reset_after limit — the rollback is refused the
same way. See Version Management.
Which one do I want?¶
Limit.quota() takes exactly one of cron or reset_after. Both build a quota — a limit
that does not drip and hands back its whole allowance in one lump — and differ only in when.
| You want | Use | Resets | Decided in |
|---|---|---|---|
| A billing period: "10,000 per calendar month" | cron="0 0 1 * *" |
At the same wall-clock instant for every caller | ADR-138 |
| A session cap: "10,000 per five hours of use" | reset_after=timedelta(hours=5) |
A fixed time after each caller's own first use | ADR-139 |
You cannot have both on one limit. A limit has one recovery mechanism
(ADR-137): a quota that reset at midnight and five hours
after first use would hand back its allowance twice over some periods and once over others, and
there would be no single number that is "the allowance". Passing both, or neither, raises
ValueError:
from datetime import timedelta
from zae_limiter import Limit
try:
Limit.quota("session", 10_000, cron="0 0 * * *", reset_after=timedelta(hours=5))
except ValueError as exc:
print(exc)
Calendar quotas are covered in Scheduled (Cron) Limits.
Setting one¶
A session quota is stored like any other limit and resolves through the normal configuration hierarchy:
from datetime import timedelta
from zae_limiter import Limit, RateLimiter, Repository
repo = await Repository.open()
limiter = RateLimiter(repository=repo)
await limiter.set_limits(
"user-123",
limits=[
Limit.quota("session", 10_000, reset_after=timedelta(hours=5)),
Limit.per_minute("rpm", 60),
],
resource="claude-sonnet",
)
async with limiter.acquire("user-123", "claude-sonnet", consume={"session": 1_500, "rpm": 1}):
... # the window opened here, at this request
A quota can sit beside ordinary rate limits on the same resource; each recovers its own way.
reset_after must be a positive whole number of seconds, at most 10⁹ (the same ceiling every
duration on a limit shares). A fractional second is rejected rather than rounded.
Idle-restarting, not tiling¶
A window does not run forward forever from the first-ever call. When a window ends while the caller is quiet, it is simply over, and the next admitted request opens a fresh one at that instant.
So a caller can reset its own window by going idle past the end of it, and that is intended — it is what "five hours from when you started" means. It is also why a swept bucket is harmless: see Limitations.
What opens a window¶
Only admitted use. A window opens on the first request after the previous one ended that is actually admitted and written — with one narrow exception: a rejected request that was also creating a new shard (or adding the quota to one) and moving tokens onto it from another shard still writes that move, with nothing consumed, so the tokens are not lost; if that request had opened a window, the window opens with it. It can happen at most once per new shard.
The admitted request does not have to name the session quota. An admitted request on the same
resource that takes the slow path — including one passing acquire(limits=[...]) without the
session quota — opens an ended window too, because it rewrites the bucket the quota lives on.
So the new window can start a little before the caller's next use of the quota itself; it never
grants more than one allowance per window. A rejected acquire(limits=[...]) request that
writes a move (the exception above) still never opens the window of a session quota outside its
limits.
A caller hammering an exhausted quota does not keep restarting its own five hours. Inside a
window, an exhausted quota's rejection writes nothing — on the fast path it is a rejection that changes nothing on the item,
and on the slow path RateLimitExceeded is raised before any write — so the anchor never moves
until the window it already opened has ended.
Cascade¶
A parent and its children anchor their windows independently. The parent's window opens at the first admitted request that debits the parent — whichever child (or the parent itself) made it — and runs its own course from there. It does not move when a child's own window restarts, and a child's window does not move when the parent's does.
from datetime import timedelta
from zae_limiter import Limit
await limiter.create_entity("org-acme")
await limiter.create_entity("user-alice", parent_id="org-acme", cascade=True)
await limiter.set_limits(
"org-acme",
limits=[Limit.quota("session", 100_000, reset_after=timedelta(hours=5))],
resource="claude-sonnet",
)
await limiter.set_limits(
"user-alice",
limits=[Limit.quota("session", 10_000, reset_after=timedelta(hours=5))],
resource="claude-sonnet",
)
# Debits both, against two separate windows.
async with limiter.acquire("user-alice", "claude-sonnet", consume={"session": 500}):
...
What it costs¶
| Event | Extra DynamoDB cost |
|---|---|
| An acquire inside a window | None. The fast path is byte-identical to any other limit: 1 WCU, 0 reads |
| A new window opening | One slow-path request per shard (S in all, as at any schedule boundary), plus (S − 1) × L conditional writes by the request that opened it, where S is the entity's shard count and L the number of reset_after limits on the bucket |
| An unsharded entity (S = 1) opening a window | No extra writes at all |
| A new shard being created mid-window | One strongly consistent read of shard 0 (1 RCU), once per shard |
The extra writes exist because a sharded entity's shards must agree on one window. Without them, "when does my quota reset" would have no honest answer. See Performance for the full breakdown.
What a 429 tells the caller¶
A rejected quota reports kind: "quota", its capacity, and resets_at_ms — the absolute
epoch-millisecond instant the window ends and the allowance returns. A client can schedule a
retry against it without parsing anything:
from datetime import timedelta
from zae_limiter import Limit, RateLimitExceeded
await limiter.set_limits(
"user-123",
limits=[Limit.quota("session", 1_000, reset_after=timedelta(hours=5))],
resource="claude-haiku",
)
async with limiter.acquire("user-123", "claude-haiku", consume={"session": 1_000}):
pass # spends the whole allowance
try:
async with limiter.acquire("user-123", "claude-haiku", consume={"session": 1}):
pass
except RateLimitExceeded as exc:
body = exc.as_dict()
session = body["limits"][0]
assert session["kind"] == "quota"
assert session["capacity"] == 1_000
assert session["resets_at_ms"] is not None # window end, epoch ms
assert "refill_amount" not in session # a quota does not drip
retry_after_seconds on the same exception is the wait until that instant.
For a display — "8,500 left · resets at 14:00" — use check_availability(), which reads every
shard once and never writes:
from datetime import timedelta
from zae_limiter import Limit
await limiter.set_limits(
"user-123",
limits=[Limit.quota("session", 10_000, reset_after=timedelta(hours=5))],
resource="claude-sonnet",
)
async with limiter.acquire("user-123", "claude-sonnet", consume={"session": 1_500}):
pass
availability = await limiter.check_availability("user-123", "claude-sonnet")
status = availability.status("session")
assert status.available == 8_500
assert status.resets_at_ms is not None # five hours after the acquire above
resets_at_ms is None when no window is live — the caller has not used the quota yet, or its
last window has ended and the next request will open a fresh one at full allowance. It is never
an instant in the past.
Viewing and declaring one¶
The CLI renders a session quota on one line, and says "after first use" so it is not mistaken for a clock-aligned period:
$ zae-limiter entity get-limits user-123 --resource claude-sonnet
Limits for entity 'user-123' on resource 'claude-sonnet':
session: 10,000 quota (resets 5h after first use)
rpm: 60/min
The -l name:rate/period flag cannot express reset_after, and a set replaces the whole
level — so entity set-limits ... -l session:10000/hour would turn a stored session quota into a
dripping limit. Use a manifest or the Python API. In a YAML manifest the length is a whole
number of seconds, and refill_amount defaults to 0:
namespace: default
resources:
claude-sonnet:
limits:
session:
capacity: 10000
reset_after_seconds: 18000 # 5h from each entity's own first use
It round-trips through the Custom::ZaeLimiterLimits CloudFormation resource as
ResetAfterSeconds.
Limitations¶
- A single request larger than one shard's balance is unadmittable while the entity is under
its configured quota. A heavily used entity is split across up to 32 shards, and one request
must fit on one shard. A shard holds at most the share it was granted this window — usually
capacity // shard_count, more on a shard granted before a doubling that has not yet passed its share on — so keep single requests well belowcapacity / 32for entities that may shard (#475). - Shards can hold uneven balances until the window ends. When an entity splits into more
shards mid-window, a new shard is funded by moving tokens off the shard whose share covers it,
never by creating new allowance, and never by discarding any
(ADR-145): a doubling neither creates nor
destroys allowance, so the entity admits its quota per window. One narrow race can let one
shard's share through twice, once: a shard created just as another client doubles the entity
(repaired immediately, so it does not recur). The price is that
balances need not be even — a shard that spent its share early passes nothing on, so a request
drawn to an empty shard is rejected while the entity still holds tokens on another. The
per-shard
capacityaRateLimitExceededreports can also differ between shards (the share each was granted). Both settle at the next window. - Shards can disagree by a few milliseconds. When two requests open a new window on two
shards at almost the same instant, each keeps its own start until the next window. The entity
still admits at most one allowance per window, and
check_availability()reports the later end. A write that fails to reach a shard leaves it staggered the same way until it next rolls. - Windows run on the clocks of the clients that open them. Clock skew between your application hosts shifts a window's end by up to that skew.
- Resource- and system-level session quotas expire with their bucket. A bucket configured
from resource or system defaults expires after
reset_after × 7idle (the default TTL multiplier) — 35 hours for a 5-hour window. The next request then opens a fresh window, which is exactly what idle-restarting specifies. Entity-level limits never expire. - The CLI's
-lflag cannot set one (above). - While v0.14 clients are still running (they ignore the session limit; see the warning at
the top):
- A v0.14 client stamps a bucket's expiry from the limits it resolves, which can be
shorter than
reset_after × 7. That covers resource- and system-level buckets, and also an entity-level bucket whose entity level holds only the session limit: v0.14 sees that level as empty, falls through to the resource or system defaults, and gives the bucket their expiry — again after every idle gap that follows one of its writes, although v0.15 removes it on its own next write. If the bucket then expires in the middle of a window, the next request starts a fresh one at the full allowance: the window restarts part-way, admitting at most one extra allowance. - A v0.14 client clears the marker that makes an ended window take the slow path, so a
shard can keep spending an ended window's leftover balance after the window's end, and
resets_at_ms/retry_after_secondscan be stale until a v0.15 request re-stamps it. That spending never adds to a window's allowance on that shard: before the next window reaches the shard it is the ended window's own leftover, and once another shard has opened the next window there, it is charged against that window's share when the shard applies it. - A shard that a v0.14 client creates does not carry the session limit. The next v0.15 request on it adds the limit by moving tokens off the shard whose share covers it, or grants a fresh share only when no shard does — the same rule as any new shard, so it neither creates nor destroys allowance (ADR-145).
- A v0.14 client stamps a bucket's expiry from the limits it resolves, which can be
shorter than
See also¶
- Scheduled (Cron) Limits — calendar quotas and time-of-day windows
- ADR-139 — the design record for the window itself
- ADR-140 — how an entity's shards share one window
- ADR-141 — why storing a
reset_afterlimit is gated on the stack's Lambda versions - ADR-142 — why a session limit's configuration is hidden from clients older than v0.15.0
- Basic Usage —
acquire(), leases, and handlingRateLimitExceeded