Skip to content

Models

Data models for rate limit configuration and status.

Limit

Limit dataclass

Limit(name, capacity, refill_amount, refill_period_seconds, schedule=(), reset_schedule=(), reset_after=None, soft=False)

Token bucket rate limit configuration.

Refill rate is stored as a fraction (refill_amount / refill_period_seconds) to avoid floating point precision issues.

Attributes:

Name Type Description
name str

Unique identifier for this limit type (e.g., "rpm", "tpm")

capacity int

Max tokens in the bucket (ceiling)

refill_amount int

Numerator of refill rate. 0 means the limit does not drip at all, and is valid only alongside a reset — a non-empty reset_schedule or a reset_after (ADR-137, ADR-139).

refill_period_seconds int

Denominator of refill rate. Inert while refill_amount is 0.

schedule tuple[ScheduleEntry, ...]

Time windows in which different parameters apply (#222). The fields above stay the base parameters forever; the schedule is applied on top of them at read time by schedule.effective_params().

reset_schedule tuple[ScheduleEntry, ...]

Calendar instants at which the balance goes back to the effective capacity in one lump (#222 §3.6). A second, independent tuple rather than a third kind of entry in schedule, because schedule is resolved first-match-wins and an entry overriding no parameters would win its window and supply nothing. Entries are built with ScheduleEntry.reset().

reset_after timedelta | None

A reset window anchored to the entity's own first use rather than to the wall clock (ADR-139): the balance goes back to the effective capacity in one lump once this long has elapsed since the window opened, and the next admitted request after that opens a fresh window. The alternative spelling of reset_schedule, never a companion to it — setting both is rejected at construction.

soft bool

Meter this limit without enforcing it (#467, ADR-151). A soft limit is debited on every admitted request and may go into debt, but never causes RateLimitExceeded; other, hard limits on the same request still gate it. Defaults to False.

A limit drips or resets, never both and never neither (ADR-137): a positive refill_amount alongside a reset_schedule would return the allowance a second time over the period, and a zero one without a reset leaves a bucket that can never recover. Both are rejected at construction, so the amount and the reset have to arrive in the same call — which is what :meth:quota is for. A quota may still carry a parameter schedule (Limit.quota(...).with_schedule(...)): that one sets the ceiling the reset restores to, and never touches refill_amount.

A reset_schedule names a fixed calendar window — every entity on one schedule resets at the same wall-clock instant (ADR-138). A reset_after names a duration window instead, anchored to each entity's own first admitted use and restarting after the entity goes idle past its end (ADR-139). A limit carries one or the other, never both.

is_quota property

is_quota

Does this limit recover at a reset edge rather than by dripping?

The structural half of ADR-137's "this limit has no refill": a property of the configuration and of nothing else. True for a quota at every instant, false for a dripping limit at every instant — including one whose effective rate happens to be zero right now.

Derived from reset_schedule rather than from refill_amount == 0 because the reset is what the limit has; the zero rate is the consequence __post_init__ pairs with it, and the pairing is what makes the two spellings interchangeable for any constructible limit. reset_schedule is also the half that survives :meth:per_shard, which must return a quota whose rate is still zero.

Since ADR-139 there are two spellings of the reset half — a cron and a duration — and this is true of both. It stays structural: a property of the configuration and of nothing else, so per_shard and from_bucket_state can rely on it without a clock.

Consumers are the ones that must treat a quota as a different kind of thing no matter what the clock says: display (10,000 per day, never 0/sec), validation, documentation. Anything asking "is this accruing right now" wants :meth:BucketState.accrues instead — a quota is only one of the two ways to get a zero rate, and the other one carries no reset_schedule to be found by.

reset_after_seconds property

reset_after_seconds

:attr:reset_after in the unit everything below the API uses.

Storage (w_{name}_rsa, b_{name}_rsa), the manifest (reset_after_seconds) and CloudFormation (ResetAfterSeconds) all carry whole seconds, because a bare scalar cannot carry a type. __post_init__ has already rejected anything that is not a positive whole number of them, so this cannot lose information.

refill_rate property

refill_rate

Tokens per second (for display/debugging).

per_second classmethod

per_second(name, rate, burst=None, *, soft=False)

Create a limit that refills rate tokens per second.

Parameters:

Name Type Description Default
name str

Limit name (e.g., "rps")

required
rate int

Sustained tokens per second (also the refill amount)

required
burst int | None

Optional burst ceiling. When set, capacity is burst and refill_amount is rate, allowing temporary spikes above the sustained rate.

None
soft bool

Meter without enforcing (#467): debited, never rejects.

False

per_minute classmethod

per_minute(name, rate, burst=None, *, soft=False)

Create a limit that refills rate tokens per minute.

Parameters:

Name Type Description Default
name str

Limit name (e.g., "rpm", "tpm")

required
rate int

Sustained tokens per minute (also the refill amount)

required
burst int | None

Optional burst ceiling. When set, capacity is burst and refill_amount is rate, allowing temporary spikes above the sustained rate.

None
soft bool

Meter without enforcing (#467): debited, never rejects.

False

per_hour classmethod

per_hour(name, rate, burst=None, *, soft=False)

Create a limit that refills rate tokens per hour.

Parameters:

Name Type Description Default
name str

Limit name (e.g., "rph")

required
rate int

Sustained tokens per hour (also the refill amount)

required
burst int | None

Optional burst ceiling. When set, capacity is burst and refill_amount is rate, allowing temporary spikes above the sustained rate.

None
soft bool

Meter without enforcing (#467): debited, never rejects.

False

per_day classmethod

per_day(name, rate, burst=None, *, soft=False)

Create a limit that refills rate tokens per day.

Parameters:

Name Type Description Default
name str

Limit name (e.g., "rpd")

required
rate int

Sustained tokens per day (also the refill amount)

required
burst int | None

Optional burst ceiling. When set, capacity is burst and refill_amount is rate, allowing temporary spikes above the sustained rate.

None
soft bool

Meter without enforcing (#467): debited, never rejects.

False

quota classmethod

quota(name, amount, *, cron=None, tz='UTC', reset_after=None, soft=False)

An allowance of amount per window, restored in one lump.

Two window shapes, and exactly one of them per limit:

cron gives a fixed calendar window — every entity on this schedule resets at the same wall-clock instant, in tz (ADR-138). That is what a billing period needs: "10,000 per calendar month" is a statement about the calendar, not about the caller.

reset_after gives a window anchored to the entity's own first use (ADR-139): five hours from when you started, not from midnight. That is what a session cap needs. The window is idle-restarting — go quiet past its end and the next call opens a fresh one. tz is meaningless here and is ignored.

Either way the limit does not drip: the balance is set to the capacity when the window opens and does not recover in between (ADR-137). The amount and the reset have to arrive together, which is why this factory exists — the intermediate value in any two-step spelling is either a drip with a reset or a zero rate with none, and Limit rejects both.

Parameters:

Name Type Description Default
name str

Limit name (e.g. "rpd", "session")

required
amount int

The whole allowance for one window (also the ceiling)

required
cron str | None

Standard 5-field cron naming the instant the window opens. Mutually exclusive with reset_after.

None
tz str

IANA timezone cron is read in. Ignored with reset_after.

'UTC'
reset_after timedelta | None

Window length, anchored to first use. Mutually exclusive with cron.

None
soft bool

Meter without enforcing (#467): the quota is debited and may go into debt within the period, never rejects, and the reset restores the allowance and forgives the debt (ADR-151).

False
10,000 a day, back to 10,000 at New York midnight

Limit.quota("rpd", 10_000, cron="0 0 * * *", tz="America/New_York")

10,000 a session, five hours from your own first call

Limit.quota("session", 10_000, reset_after=timedelta(hours=5))

custom classmethod

custom(name, capacity, refill_amount, refill_period_seconds, *, soft=False)

Create a custom limit with explicit refill rate.

Allow ceiling of 1000 tokens, sustained at 100/sec

Limit.custom("requests", capacity=1000, refill_amount=100, refill_period_seconds=1)

with_schedule

with_schedule(schedule)

This limit with a schedule attached (#222 §1.1).

Returns a new instance; Limit is frozen and the factory methods (per_minute, per_hour, ...) do not take a schedule. Validation runs through __post_init__, so a schedule whose entries disagree on timezone is rejected here rather than at the DynamoDB write.

Pass () to clear a schedule: storage is override-not-merge, so a limit with no schedule has no schedule.

with_reset_schedule

with_reset_schedule(reset_schedule)

Swap one quota's reset schedule for another (#222 §3.6).

A replacement operator on a limit that is already a quota, not the way a quota is built — use :meth:quota for that. ADR-137 leaves nothing else standing: this method returns a limit whose refill_amount is whatever it already was, so calling it on a dripping limit produces a rate alongside a reset and raises, and calling it with () on a quota produces a zero rate with no reset and raises too. Both are rejected here rather than silently repaired, because either repair would throw away a number the caller explicitly passed.

Entries must come from :meth:ScheduleEntry.reset; a parameter entry is rejected rather than silently stored, since the two mean different things to every reader downstream.

Independent of :meth:with_schedule, which a quota may also carry: the parameter schedule decides what the ceiling is, the reset schedule decides when the balance returns to it.

to_dict

to_dict()

Serialize to dictionary for storage.

from_dict classmethod

from_dict(data)

Deserialize from dictionary.

from_bucket_state classmethod

from_bucket_state(state)

The base limit a bucket item encodes, schedule included.

Bucket items store the undivided, unscaled base parameters forever (#222 §2.1) alongside the schedule that applies on top of them, so what comes back here is the configured limit — not the view one shard has of it right now. Narrowing to a shard and to the window in force is :meth:per_shard, which does both together; calling it on this result divides and scales exactly once.

This deliberately does not pre-divide. The result is what LeaseEntry.limit carries, and the slow path already puts the undivided config limit there; a pre-divided one would be narrowed a second time when the lease builds a status from it.

reset_schedule and the max(1, ...) rate floor move together, because neither is correct without the other: the floor turns a quota's zero rate into one token, and a positive rate alongside a reset is precisely what ADR-137 rejects, so carrying the tuple on its own would start raising from inside a rejection path rather than round-tripping. Carried together, a quota bucket reconstructs as the quota it is — refill_amount=0, reset intact — instead of advertising a phantom one-token drip beside a retry_after_seconds computed from the calendar edge.

The pairing is decided by the stored shape, not by either half alone. Since ADR-139 a quota item can carry either spelling of the reset — reset_sched (a cron) or reset_after_seconds (a duration) — so both are consulted, and two shapes are corrupt rather than legally constructible. Neither may reach the constructor call below, which would raise from inside a rejection path instead of round-tripping — the same discipline the positive-rate case already followed, now stated for both:

  • A reset beside a positive rate (refill_amount_milli > 0) is unconstructible under ADR-137: it keeps the rate floor and drops both reset tuples, preserving the pre-#222 reading — the same call bucket.calculate_retry_after's last branch makes. A duration window read back as a dripping limit would advertise a phantom one-token drip beside a retry_after_seconds computed from a rate that does not exist.
  • Both reset tuples at once beside a zero rate is unconstructible under ADR-139 (a limit resets one way, never both). reset_sched — the pre-ADR-139 reading — wins and reset_after_seconds is dropped, so the bucket still reconstructs as the quota it is rather than raising two-recovery-mechanisms from inside a rejection path.

state.reset_sched and state.reset_after_seconds are populated by the slow path (from the resolved config), by BucketState.from_limit, and by _deserialize_composite_bucket, which decodes rsched and b_{name}_rsa off the item — so a bucket read back from DynamoDB, including a speculative-path ALL_OLD / ALL_NEW image, reconstructs as the quota it is.

per_shard

per_shard(shard_count, now_ms, topped_up_milli=0)

This limit as a single shard of shard_count sees it at now_ms.

Two narrowings happen here, and the order matters (#222 §2.1): the schedule in force at now_ms scales the undivided parameters, and only then does the shard take its share — capacity // shard_count, refill_amount // shard_count (GHSA-76rv). Dividing first and scaling second is a different integer, and it is the wrong one: the bucket itself refills against BucketState.ceiling_milli, which scales first.

Both narrowings belong together because both feed LimitStatus and neither may be applied twice. A status reported from one shard has to quote the share, or it promises a capacity no shard can serve and, for a request larger than the share, a retry_after_seconds that never pays off (#475). A status reported inside a 0.5x window has to quote the scaled value for the same reason.

refill_period_seconds follows the window — an absolute schedule entry may override it — but is never divided: shards split the numerator and every shard refills on the same clock.

The result carries no schedule. It is a point-in-time materialisation, and leaving the schedule attached would invite a second application and read to a caller as "500, which halves to 250".

reset_schedule is carried through, for the mirror-image reason: nothing here applies it, so there is nothing to apply twice, and it is the only thing that tells a reported status the balance returns in a lump at a calendar instant rather than dripping back at refill_amount. Dropping it would leave a daily-quota rejection quoting a wait computed from the drip alone.

reset_after is carried through for the identical reason, and the is_quota carve-out on the rate floor already covers it because that predicate is structural and asks both spellings.

Shares are floored to one whole token because Limit is whole-token and must stay constructible; schema.MAX_SHARD_COUNT bounds how small a real share can get, and a 0.5x window on a share of one would otherwise raise from inside a rejection path. Surfacing an unadmittable request as an event or metric is tracked in #475.

That floor is not applied to a quota's refill_amount, which is zero and must stay zero (ADR-137). Flooring it to one would both invent a drip the operator never configured and make the result unconstructible, since a positive rate alongside the reset_schedule this carries through is exactly what validation rejects. The carve-out asks :attr:is_quota, the structural predicate, deliberately: no drip, scaled by any window and divided by any shard count, is still no drip, so the question must not depend on now_ms. The temporal predicate would be the wrong one here — it is also true of a dripping limit whose share has floored away, and zeroing that limit's rate would report a recovering limit as one that never recovers.

topped_up_milli is the drawn shard's b_{name}_tu (ADR-149): a quota shard topped up above its grant reports C // gc + tu, the ceiling it really holds, so a purchase is not hidden from a 429.

Session windows: Limit.reset_after

reset_after: timedelta | None is the second spelling of a quota's reset (ADR-139): the allowance returns this long after the entity's own first admitted use, and the next admitted request after that opens a fresh window. Build one with Limit.quota(name, capacity, reset_after=timedelta(...)); quota() takes exactly one of cron or reset_after. It must be a positive whole number of seconds, at most 10⁹. Limit.reset_after_seconds is the same value as an int, the spelling used by to_dict(), YAML manifests (reset_after_seconds) and CloudFormation (ResetAfterSeconds). See Session Quotas.

ScheduleEntry

One window of a limit's schedule, or one edge of its reset_schedule. Build reset entries with ScheduleEntry.reset(), which takes cron and tz only.

Everything is validated at construction, so an entry that applies is an entry that evaluates. ValueError is raised for a cron expression that will not parse, a tz that is not a resolvable IANA name, an entry setting neither scale nor an absolute field or setting both, and any modifier that is not positive and finite. The absolute fields — capacity, refill_amount, refill_period_seconds — are integers, so a fractional value such as 100.5 is rejected rather than rounded. scale is the field that takes a fraction.

ScheduleEntry dataclass

ScheduleEntry(cron, tz='UTC', scale=None, capacity=None, refill_amount=None, refill_period_seconds=None, _reset=False)

One window of a schedule: a cron pattern and the limit that applies in it.

The cron expression is a match pattern, not a fire time: the entry is active for every minute the pattern matches (§1.2). * 9-17 * * MON-FRI is business hours, not "once at the top of each of those hours".

A reset entry (ScheduleEntry.reset) is the one exception to both halves of that sentence. It carries cron and tz and nothing else, because it overrides no parameters (§3.6), and it is read as an edge rather than a window: it fires on the transition into matching, not for every minute of it. The two kinds live in separate tuples on Limit (schedule and reset_schedule) and are validated by opposite rules, so the flag is private and set only by the classmethod.

reset classmethod

reset(cron, tz='UTC')

An entry that resets the balance rather than changing the params.

The instant this expression starts matching, the bucket's tk goes back to the effective capacity in force there (§3.6) — the one thing a token bucket cannot express, and what makes "10,000 a day, back to 10,000 at midnight" different from a 24-hour refill period.

Belongs in Limit.reset_schedule, never in Limit.schedule: the parameter tuple is resolved first-match-wins, so an entry that supplies no parameters would win its window and then shadow every entry below it.

Entity

Entity dataclass

Entity(id, name=None, parent_id=None, cascade=False, metadata=dict(), created_at=None, parent_generation=0, incarnation='-')

An entity that can have rate limits applied.

Entities can be parents (projects) or children (API keys). Children have a parent_id reference.

Note: This model does not validate in post_init to support DynamoDB deserialization and avoid performance overhead. Validation is performed in Repository.create_entity() at the API boundary.

parent_generation class-attribute instance-attribute

parent_generation = field(default=0, compare=False)

The entity's parent generation (ADR-150): raised by every move to a new parent.

Starts at the creation instant in epoch ms, so a recreated entity is never below a bucket its predecessor left behind; 0 for an entity created before v0.17. Every bucket write of the entity's parent_id carries it, so a writer that read an older generation can never put a previous parent back. Not part of equality: two reads of one entity are equal across a move's generation only through parent_id.

incarnation class-attribute instance-attribute

incarnation = field(default='-', compare=False)

A random token naming this creation of the entity (ADR-150).

A generation is comparable only within one incarnation, so a bucket left behind by a deleted predecessor never outranks the recreated entity. "-" for an entity created before v0.17, which had none.

is_parent property

is_parent

True if this entity has no parent (is a root/project).

is_child property

is_child

True if this entity has a parent.

LimitStatus

LimitStatus dataclass

LimitStatus(entity_id, resource, limit_name, limit, available, requested, exceeded, retry_after_seconds, resets_at_ms=None)

Status of a specific limit check.

Returned in RateLimitExceeded to provide full visibility into all limits that were checked.

Note: This is an internal model created by the limiter from validated inputs. No validation is performed here to avoid performance overhead.

entity_id instance-attribute

entity_id

The entity the limit was checked for.

resource instance-attribute

resource

The resource the limit was checked on.

limit_name instance-attribute

limit_name

Name of the limit, e.g. "rpm".

limit instance-attribute

limit

The limit as checked. Inside RateLimitExceeded this is one shard's in-window share (Limit.per_shard, #475); inside Availability it is the undivided configuration.

available instance-attribute

available

Tokens currently available. Can be negative (debt).

requested instance-attribute

requested

Tokens requested.

exceeded instance-attribute

exceeded

True if requested exceeds available.

retry_after_seconds instance-attribute

retry_after_seconds

Seconds until requested is available; 0 if not exceeded.

resets_at_ms class-attribute instance-attribute

resets_at_ms = None

When a session quota's current window ends, as absolute epoch milliseconds (Limit.reset_after, ADR-139).

Read off the bucket item, where the window's start is stored, and never an instant at or before the moment it was computed. None for a rate limit, for a calendar quota (reset_schedule — its next edge is recoverable from the cron, and RateLimitExceeded.as_dict computes it there), and for a session quota with no live window, whose allowance is already back. Inside RateLimitExceeded it describes the shard the request was tried on; inside Availability it is the latest live window end across the entity's shards.

deficit property

deficit

How many tokens short we are (0 if not exceeded).

soft property

soft

Whether the limit is soft: metered, never a reason to reject (#467).

A soft status is never exceeded and its retry_after_seconds is 0.0; inside RateLimitExceeded it is always among passed.

overdrawn property

overdrawn

A soft limit running in debt: soft and available < 0 (#467).

Availability

Availability dataclass

Availability(entity_id, resource, checked_at_ms, statuses, bypassed=False)

Combined result of a non-consuming capacity check (issue #472).

Answers both "how much is left?" and "how long until I can proceed?" for every resolved limit, from a single config resolution and a single bucket read taken at one instant. Returned by :meth:RateLimiter.check_availability.

The per-limit detail lives in :attr:statuses, reusing the same :class:LimitStatus that :class:~zae_limiter.exceptions.RateLimitExceeded carries, so a caller that renders a rejection and a caller that renders a dashboard read the same shape. Everything else on this class is derived from those statuses, which is what makes the numbers consistent: they all come from one snapshot rather than from separate reads at separate instants.

One difference from the statuses in RateLimitExceeded: those report a single shard's share of the limit (Limit.per_shard(), #475), because a rejection happened on one shard. These report the entity-wide total summed across every shard, against the undivided configured :class:Limit — which is the number a user's "N remaining" display means.

Note: This is an internal model created by the limiter from validated inputs. No validation is performed here to avoid performance overhead.

limits property

limits

The resolved limits this snapshot was taken against.

available property

available

limit_name -> currently available tokens (negative if in debt).

needed property

needed

limit_name -> amount the caller asked about.

Empty when none was given. Keys that named no resolved limit are not echoed back, since nothing was evaluated for them.

retry_after_seconds property

retry_after_seconds

Seconds until every needed amount is available (0.0 if already so).

allowed property

allowed

True if every needed amount is currently available.

exceeded property

exceeded

Names of the limits that are short of the needed amount.

deficit property

deficit

How many tokens short each exceeded limit is (exceeded limits only).

status

status(limit_name)

The status for one limit, or None if it was not resolved.

BucketOperationResult

BucketOperationResult dataclass

BucketOperationResult(entity_id, resource, shards, amounts)

What Repository.reset_bucket or Repository.top_up did (ADR-149).

Attributes:

Name Type Description
entity_id str

The entity operated on.

resource str

The resource operated on.

shards int

Bucket items written (0 when there was nothing to write, such as a reset of an entity that has never acquired).

amounts dict[str, int]

Per named limit, in whole tokens. For a top-up, what was granted: exactly what was asked for a quota, at most the free room below the ceiling for a dripping limit. For a reset, what was restored across every shard.

BucketState

BucketState dataclass

BucketState(entity_id, resource, limit_name, tokens_milli, last_refill_ms, capacity_milli, refill_amount_milli, refill_period_ms, total_consumed_milli=None, shard_count=1, sched=(), reset_sched=(), window_start_ms=None, reset_after_seconds=None, window_applied_ms=None, window_consumed_mark_milli=None, grant_count=None, topped_up_milli=None, reset_number=None, stored_vu_ms=None, stored_vu_read=False, bypass=False, bypass_until_ms=None, soft=False, soft_until_ms=None, stored_item_sched_raw=None, stored_sched_raw=None)

Internal state of a token bucket.

All token values are stored in millitokens (x1000) for precision.

Note: This is an internal model. Validation is performed in from_limit() for user-provided inputs, not in post_init to support DynamoDB deserialization and avoid performance overhead on frequent operations.

tokens property

tokens

Current tokens (not millitokens).

capacity property

capacity

Capacity / ceiling (not millitokens).

is_quota_state property

is_quota_state

Whether this state is a quota (ADR-137): a reset schedule or a duration window.

grant_shard_count property

grant_shard_count

The count this shard's quota grant was sized at (ADR-145).

An item written before the grant record reads as its stored count (design §9, owner decision option 1).

report_shard_count property

report_shard_count

The divisor a reported per-shard limit uses (Limit.per_shard).

A quota shard reports what it was granted (C // gc), so a 429 never shows more available than capacity. Everything else reports the share.

report_top_up_milli property

report_top_up_milli

The tu a reported per-shard limit adds to its capacity (ADR-149).

Only a quota carries one; a stray one on anything else is ignored.

window_end_ms property

window_end_ms

When the current duration window closes, or None (ADR-139).

Derived rather than stored, so the start and the end cannot disagree after a partial write, and because reset_after_seconds has to be on the item anyway for the next window's length.

window_rolled property

window_rolled

Has a window opened that this shard's balance does not reflect yet?

ws > wa (ADR-140, #640): a shard whose window start is newer than the window its balance was last materialised for has not applied that window. This is the single statement of the rule — the slow-path roll (RateLimiter._apply_window_roll) and every read-only view of the balance ask it here rather than restating the comparison, so the two cannot drift (the #489 lesson). The aggregator mirrors it in processor._window_applied.

An item written before the marker existed carries no wa, and falls back to the ADR-140 rule it was written under, ws > rf. The marker exists because rf is shared: a writer predating ADR-139 stamps it from its own clock, backward (the next pass would roll again) or forward past a fanned-out ws (the shard would never roll it).

Strictly >: the pass that applies a roll records wa = ws, so >= would re-fire on every later request and refund everything spent since. False for a bucket with no window.

reset_target_milli

reset_target_milli(now_ms)

The balance a reset or window roll sets: this shard's share at now_ms.

Scale first, divide second (#222 §2.1): the schedule applies to the whole limit and the shards split the result. Dividing first floors against a smaller numerator and drifts below the intended share.

fast_path_short

fast_path_short(amount)

Whether this image fails the fast path's balance term for amount tokens.

The speculative condition's per-limit term, read off a failure image: tk >= amount, waived for a soft limit (#467) or a bypassed bucket (#311), whose stamps admit it. A stamp seen here is live: an expired one is classified first, as a closed window. One predicate for the repository's failure classification and the limiter's "failed only on its stamp" test (#741), so the two cannot disagree.

ceiling_milli

ceiling_milli(now_ms)

The most this shard may hold at now_ms — the refill clamp (ADR-145).

A quota shard holds the unspent allowance of every slot its grant covers, so its ceiling is C // gc; trimming it to C // S would discard allowance no other shard holds (#637). A dripping limit keeps C // S: its shards' ceilings must sum to the capacity.

A quota shard an operator topped up above its grant this period (ADR-149) holds that allowance too: C // gc + tu. A dripping limit never carries tu.

effective_refill_amount_milli

effective_refill_amount_milli(now_ms)

This shard's share of the refill in force at now_ms.

Scale first, divide second, exactly as reset_target_milli.

effective_refill_period_ms

effective_refill_period_ms(now_ms)

The refill denominator in force at now_ms.

A ScheduleEntry absolute override may replace refill_period_seconds, so the period is time-dependent exactly as the capacity and the refill amount are. Unlike those two it is not divided by shard_count: every shard refills on the same clock and only the numerator is split.

window_roll_target_milli

window_roll_target_milli(now_ms)

The balance a pending window roll restores this shard to (#640).

eff_cp - max(0, tc - wtc): the shard's effective share, less every net debit since the fan-out snapshot. That is exactly the balance an immediate roll at the snapshot instant would have left — every debit path ADDs tc (the fast path, the normal path, the retry, adjust and rollback, on this release and on v0.14), so nothing spent while the roll was pending is forgiven by it, including spending from the ended window's leftover after a writer predating ADR-139 removed vu. The target does not read tk at all, so it does not matter what the shard spent from. max(0, ...) keeps net credits since the snapshot (an old-window lease rolling back) from lifting the roll above the share. With no snapshot on the item it is the share, as before.

Only meaningful for a roll that is pending (:attr:window_rolled); an opener or a newly created shard applies its window in the same write and restores the full share.

accrues

accrues(now_ms)

Is this shard gaining tokens at now_ms?

The temporal half of ADR-137's "this limit has no refill", bound to a bucket and a clock: :func:is_accrual_rate applied to the rate this shard actually refills at right now, schedule and shard split included.

False for two unrelated reasons, which is the whole point of keeping it apart from :attr:Limit.is_quota:

  • the limit is a quota and never drips (ra is 0 on the item); or
  • the limit drips, but this shard's share of the rate in force floors to zero — a slow limit split many ways, or a scale window that shrank the numerator before // shard_count reached it.

A caller that special-cases only the first gets the second wrong silently, because a sharded dripping limit carries nothing that marks it. Anything that divides by the refill rate, or computes a wait from it, must ask this question; anything that decides how to describe the limit — display, validation — wants :attr:Limit.is_quota, which does not move with the clock.

Note what this does not say: a bucket that is not accruing may still recover, at the next reset_schedule edge for a quota, or on the next re-materialisation for a share that a widening window brings back above zero.

retry_refill_amount_milli

retry_refill_amount_milli(now_ms)

Refill rate to use for a "seconds until available" estimate.

effective_refill_amount_milli floors to 0 for a slow refill on a heavily sharded bucket (1 token/minute at shard_count=1024), and a rate of 0 has no finite wait at all. Fall back to the undivided rate rather than raise or invent infinity: the estimate is then optimistic by up to shard_count, but it is finite and honest about the rate the limit itself refills at, and a retry draws a shard at random — so it may well land somewhere that admits. schema.MAX_SHARD_COUNT bounds how far apart the two can get.

The fallback is the scheduled undivided rate, not the stored base rate: during a scale window the base rate is a speed nothing in the system refills at, so quoting it would under-report the wait.

A quota falls through to the same fallback and gets 0 back, because the undivided rate is 0 too — the estimate is then not a rate question at all, and bucket.calculate_retry_after answers it from the next reset edge instead (#530). That is why the guard here is :func:is_accrual_rate and not :attr:Limit.is_quota: it has to catch the floored share, which is not a quota and must not be treated as one.

Since #222 §7 the retry estimate is computed by schedule.retry_after_with_schedule, which re-derives this rule per window from the undivided base rather than calling this — it has to, because schedule may not import models (that one-way dependency is what lets both Lambdas vendor it). This stays the definition of the rule, and the walk's _rate helper names it.

from_limit classmethod

from_limit(entity_id, resource, limit, now_ms, shard_count=1, starting_tokens_milli=None)

Create a new bucket at full capacity from a Limit.

Note: This is an internal factory method. Validation of entity_id and resource is performed at the API boundary (RateLimiter public methods) before calling this method.

Parameters:

Name Type Description Default
entity_id str

Entity identifier (pre-validated by caller)

required
resource str

Resource name (pre-validated by caller)

required
limit Limit

Limit configuration (validated via post_init)

required
now_ms int

Current time in milliseconds

required
shard_count int

Shards the bucket is split across; a new shard starts at its effective share, capacity // shard_count

1
starting_tokens_milli int | None

The balance a quota shard is created or seeded with when it takes its share by an ADR-145 move off the sibling covering its slot (QuotaGrant.tokens_milli). None (the default, every dripping limit, and a quota whose slot no current-period sibling covers) starts at the full share, BucketState.reset_target_milli. A quota's grant_count is stamped shard_count either way (I3).

None

AuditEvent

AuditEvent dataclass

AuditEvent(event_id, timestamp, action, entity_id, principal=None, resource=None, details=dict())

Security audit event for tracking modifications.

Audit events are logged for security-sensitive operations: - Entity creation and deletion - Limit configuration changes

Attributes:

Name Type Description
event_id str

Unique identifier for the event (timestamp-based)

timestamp str

ISO timestamp when the event occurred

action str

Type of action (see AuditAction constants)

entity_id str

ID of the entity affected

principal str | None

Caller identity who performed the action (optional)

resource str | None

Resource name for limit-related actions (optional)

details dict[str, Any]

Additional action-specific details

to_dict

to_dict()

Serialize to dictionary for storage.

from_dict classmethod

from_dict(data)

Deserialize from dictionary.

AuditAction

AuditAction

Audit action type constants.

UsageSnapshot

UsageSnapshot dataclass

UsageSnapshot(entity_id, resource, window_start, window_end, window_type, counters, total_events, overdrawn=dict())

Aggregated usage for a time window.

Created by the aggregator Lambda from DynamoDB stream events. Tracks token consumption per limit type within a time window.

Attributes:

Name Type Description
entity_id str

Entity that consumed tokens

resource str

Resource being rate-limited (e.g., "gpt-4")

window_start str

ISO timestamp of window start (e.g., "2024-01-01T14:00:00Z")

window_end str

ISO timestamp of window end

window_type str

Window granularity ("hourly", "daily")

counters dict[str, int]

Consumption by limit name (e.g., {"tpm": 5000, "rpm": 10})

total_events int

Number of consumption events in this window

overdrawn dict[str, int]

Soft limits only (#467): limit name -> number of stream records in the window where the limit was debited and left its shard in debt. Empty when no soft limit was overdrawn.

UsageSummary

UsageSummary dataclass

UsageSummary(snapshot_count, total, average, min_window_start, max_window_start)

Aggregated usage summary across multiple snapshots.

Returned by RateLimiter.get_usage_summary() to provide total and average consumption statistics over a time range.

Attributes:

Name Type Description
snapshot_count int

Number of snapshots aggregated

total dict[str, int]

Sum of consumption by limit name (e.g., {"tpm": 50000, "rpm": 100})

average dict[str, float]

Average consumption per snapshot by limit name

min_window_start str | None

Earliest snapshot window start (ISO timestamp)

max_window_start str | None

Latest snapshot window start (ISO timestamp)

Example

summary = await limiter.get_usage_summary( entity_id="user-123", resource="gpt-4", window_type="hourly", ) print(f"Total tokens: {summary.total.get('tpm', 0)}") print(f"Average per hour: {summary.average.get('tpm', 0.0):.1f}")

LimiterInfo

LimiterInfo dataclass

LimiterInfo(stack_name, user_name, region, stack_status, creation_time, last_updated_time=None, version=None, lambda_version=None, schema_version=None, stack_type=None)

Information about a deployed rate limiter instance.

Represents a CloudFormation stack discovered in a region via RateLimiter.list_deployed() or the zae-limiter list CLI command. This is a READ-ONLY model describing observed infrastructure state.

Example

Discover all limiters in us-east-1

limiters = await RateLimiter.list_deployed(region="us-east-1") for limiter in limiters: if limiter.is_failed: print(f"⚠️ {limiter.user_name}: {limiter.stack_status}")

Attributes:

Name Type Description
stack_name str

Full CloudFormation stack name (e.g., "my-app")

user_name str

User-friendly name (e.g., "my-app")

region str

AWS region where the stack is deployed

stack_status str

CloudFormation stack status (e.g., "CREATE_COMPLETE")

creation_time str

ISO 8601 timestamp of stack creation

last_updated_time str | None

ISO 8601 timestamp of last update (None if never updated)

version str | None

Value of zae-limiter:version tag (client version at deployment)

lambda_version str | None

Value of zae-limiter:lambda-version tag

schema_version str | None

Value of zae-limiter:schema-version tag

is_healthy property

is_healthy

Stack is in a stable, operational state.

is_in_progress property

is_in_progress

Stack operation is in progress.

is_failed property

is_failed

Stack is in a failed or rollback state.

StackOptions

StackOptions dataclass

StackOptions(snapshot_windows='hourly,daily', usage_retention_days=90, audit_retention_days=90, enable_aggregator=True, enable_provisioner=True, pitr_recovery_days=None, log_retention_days=30, lambda_timeout=60, lambda_memory=256, enable_alarms=True, alarm_sns_topic=None, lambda_duration_threshold_pct=80, permission_boundary=None, role_name_format=None, policy_name_format=None, enable_audit_archival=True, audit_archive_glacier_days=90, enable_tracing=False, create_iam_roles=False, create_iam=True, aggregator_role_arn=None, enable_deletion_protection=False, tags=None)

Configuration options for CloudFormation stack creation and updates.

When passed to RateLimiter constructor, triggers automatic stack creation. When None is passed (default), no stack creation is attempted.

Attributes:

Name Type Description
snapshot_windows str

Comma-separated list of snapshot windows (e.g., "hourly,daily")

usage_retention_days int

Number of days to retain usage snapshots

audit_retention_days int

Number of days to retain audit records in DynamoDB

enable_aggregator bool

Deploy Lambda aggregator for usage snapshots

enable_provisioner bool

Deploy Lambda provisioner for declarative limits

pitr_recovery_days int | None

Point-in-Time Recovery period (1-35, None for AWS default)

log_retention_days int

CloudWatch log retention period in days (must be valid CloudWatch value)

lambda_timeout int

Lambda timeout in seconds (1-900)

lambda_memory int

Lambda memory size in MB (128-3008)

enable_alarms bool

Deploy CloudWatch alarms for monitoring

alarm_sns_topic str | None

SNS topic ARN for alarm notifications

lambda_duration_threshold_pct int

Duration alarm threshold as percentage of timeout (1-100)

permission_boundary str | None

IAM permission boundary (policy name or full ARN)

role_name_format str | None

Format template for role name, {} = default role name

policy_name_format str | None

Format template for managed policy name, {} = default policy name

enable_audit_archival bool

Archive expired audit events to S3 via TTL

audit_archive_glacier_days int

Days before transitioning archives to Glacier IR (1-3650)

enable_tracing bool

Enable AWS X-Ray tracing for Lambda aggregator

create_iam_roles bool

Create App/Admin/ReadOnly IAM roles (default: False). Managed policies are always created unless create_iam=False.

create_iam bool

Create IAM resources (policies and roles). Set to False for restricted IAM environments (e.g., PowerUserAccess). When False, aggregator is disabled unless aggregator_role_arn is provided.

aggregator_role_arn str | None

ARN of an existing IAM role for the Lambda aggregator. Use this when deploying without iam:CreateRole permissions.

enable_deletion_protection bool

Enable DynamoDB table deletion protection

tags dict[str, str] | None

User-defined tags to apply to the CloudFormation stack. Dict of key-value pairs. AWS tag constraints apply (max 50 total including managed tags, key 1-128 chars, value 0-256 chars). The aws: prefix is reserved.

deploys_aggregator_lambda property

deploys_aggregator_lambda

Whether CloudFormation will create the aggregator Lambda function.

Mirrors the template's DeployAggregatorLambda condition: the aggregator needs a role, so it is only created when IAM resources are enabled or an external role ARN was supplied.

deploys_provisioner_lambda property

deploys_provisioner_lambda

Whether CloudFormation will create the limits provisioner Lambda function.

Mirrors the template's DeployProvisionerLambda condition. The provisioner has no external-role escape hatch, so it requires create_iam.

__post_init__

__post_init__()

Validate options and emit deprecation warning.

get_role_name

get_role_name(stack_name, component)

Get the final role name for a given stack name and component.

Parameters:

Name Type Description Default
stack_name str

Stack name

required
component str

Role component (aggr, app, admin, read)

required

Returns:

Type Description
str | None

Final role name, or None if role_name_format not set

Raises:

Type Description
ValidationError

If resulting name exceeds 64 characters

get_policy_name

get_policy_name(stack_name, component)

Get the final policy name for a given stack name and component.

Parameters:

Name Type Description Default
stack_name str

Stack name

required
component str

Policy component (app, admin, read)

required

Returns:

Type Description
str | None

Final policy name, or None if policy_name_format not set

Raises:

Type Description
ValidationError

If resulting name exceeds 128 characters

to_parameters

to_parameters(stack_name=None)

Convert to stack parameters dict for StackManager.

Parameters:

Name Type Description Default
stack_name str | None

Stack name for role_name_format substitution

None

Returns:

Type Description
dict[str, str]

Dict with snake_case keys matching stack_manager parameter mapping.

Status

Status dataclass

Status(available, latency_ms, stack_status, table_status, aggregator_enabled, name, region, schema_version, lambda_version, client_version, table_item_count, table_size_bytes, app_role_arn=None, admin_role_arn=None, readonly_role_arn=None)

Comprehensive status of a rate limiter instance.

Consolidates connectivity, infrastructure, identity, versions, and table metrics into a single status object. Used by the CLI status command.

Attributes:

Name Type Description
available bool

Whether DynamoDB is reachable and responding

latency_ms float | None

Round-trip latency in milliseconds (None if unavailable)

stack_status str | None

CloudFormation stack status (e.g., 'CREATE_COMPLETE')

table_status str | None

DynamoDB table status (e.g., 'ACTIVE')

aggregator_enabled bool

Whether Lambda aggregator is deployed

name str

Resource name

region str | None

AWS region (None if using default)

schema_version str | None

Deployed schema version

lambda_version str | None

Deployed Lambda version

client_version str

Current client library version

table_item_count int | None

Approximate item count in table

table_size_bytes int | None

Approximate table size in bytes

app_role_arn str | None

IAM role ARN for applications (None if roles disabled)

admin_role_arn str | None

IAM role ARN for administrators (None if roles disabled)

readonly_role_arn str | None

IAM role ARN for read-only access (None if roles disabled)

BackendCapabilities

BackendCapabilities dataclass

BackendCapabilities(supports_audit_logging=False, supports_usage_snapshots=False, supports_infrastructure_management=False, supports_change_streams=False, supports_batch_operations=False)

Declares which extended features a backend supports.

Used by RateLimiter to gracefully degrade when features are unavailable. Backend implementations should return an instance from their capabilities property.

See ADR-109 for the capability matrix across backends.

supports_audit_logging class-attribute instance-attribute

supports_audit_logging = False

Whether the backend supports audit event storage and retrieval.

supports_usage_snapshots class-attribute instance-attribute

supports_usage_snapshots = False

Whether the backend supports usage snapshot aggregation.

supports_infrastructure_management class-attribute instance-attribute

supports_infrastructure_management = False

Whether the backend supports declarative infrastructure (e.g., CloudFormation).

supports_change_streams class-attribute instance-attribute

supports_change_streams = False

Whether the backend supports real-time change notifications.

supports_batch_operations class-attribute instance-attribute

supports_batch_operations = False

Whether the backend supports batch_get_buckets() for optimized reads.

ResourceCapacity

ResourceCapacity dataclass

ResourceCapacity(resource, limit_name, total_capacity, total_available, utilization_pct, entities)

Aggregated capacity info for a resource across entities.

EntityCapacity

EntityCapacity dataclass

EntityCapacity(entity_id, capacity, available, utilization_pct)

Capacity info for a single entity.

SpeculativeResult

SpeculativeResult dataclass

SpeculativeResult(success, buckets=list(), cascade=False, parent_id=None, old_buckets=None, parent_result=None, shard_id=0, shard_count=1, failure_reason=None, bypassed=False, pgen=None, pinc=None, debited_parent_id=None)

Result of a speculative UpdateItem attempt.

Attributes:

Name Type Description
success bool

True if the speculative write succeeded.

buckets list[BucketState]

On success, deserialized BucketStates from ALL_NEW response. Includes the wcu infrastructure limit bucket state.

cascade bool

On success, whether the entity has cascade enabled.

parent_id str | None

On success, the entity's parent_id (if any).

old_buckets list[BucketState] | None

On failure, deserialized BucketStates from ALL_OLD response. None if the bucket doesn't exist (first acquire).

parent_result SpeculativeResult | None

On cache hit + cascade, nested parent SpeculativeResult from parallel UpdateItem. None for cache miss, non-cascade, or failure.

shard_id int

The shard index targeted by this speculative write.

shard_count int

The total shard count read from the bucket item. Used by the limiter to decide whether to retry on another shard or double shards when the wcu limit is exhausted.

pgen int | None

The parent generation stamped on the item (ADR-150), or None when the item carries none. A stamp with a generation is authoritative for cascade and parent_id even when it names no parent.

pinc str | None

The entity incarnation stamped beside pgen (ADR-150), or None. A generation compares only within one incarnation.

debited_parent_id str | None

On the warm parallel cascade path, the parent the nested write in parent_result went to: the cached parent, which the item's parent_id can contradict after a move (ADR-150). None when no parallel parent write was issued.

stamp_is_policy property

stamp_is_policy

Whether the item's cascade / parent_id stamp can be trusted.

A stamp carrying a parent generation is authoritative (ADR-150); one without is trusted only when it carries a parent_id (ADR-146: a cascade=False with no parent_id and no generation is a bucket an older version created for a parent from its child's view, #684).

CacheStats

CacheStats dataclass

CacheStats(hits=0, misses=0, size=0, ttl_seconds=0, local_rejections=0)

Statistics for cache performance monitoring.

as_dict

as_dict()

Return stats as a dictionary.