Skip to content

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 deploy or upgrade --force from v0.14 puts the v0.14 Lambdas back and stamps lambda_version = 0.14.0.
  • A v0.14 upgrade treats the raised minimum as "not up to date", so it too downgrades the Lambdas — and resets client_min_version to 0.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.

gantt title reset_after = 5h, one caller dateFormat HH:mm axisFormat %H:%M section Windows window 1 (first use 09:00) :active, w1, 09:00, 14:00 idle :done, i1, 14:00, 16:30 window 2 (next use 16:30) :active, w2, 16:30, 21:30

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 below capacity / 32 for 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 capacity a RateLimitExceeded reports 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 × 7 idle (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 -l flag 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_seconds can 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).

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_after limit 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 handling RateLimitExceeded