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. |
refill_period_seconds |
int
|
Denominator of refill rate. Inert while
|
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
|
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 |
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
|
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 |
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
¶
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
¶
: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.
per_second
classmethod
¶
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, |
None
|
soft
|
bool
|
Meter without enforcing (#467): debited, never rejects. |
False
|
per_minute
classmethod
¶
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, |
None
|
soft
|
bool
|
Meter without enforcing (#467): debited, never rejects. |
False
|
per_hour
classmethod
¶
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, |
None
|
soft
|
bool
|
Meter without enforcing (#467): debited, never rejects. |
False
|
per_day
classmethod
¶
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, |
None
|
soft
|
bool
|
Meter without enforcing (#467): debited, never rejects. |
False
|
quota
classmethod
¶
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 |
None
|
tz
|
str
|
IANA timezone |
'UTC'
|
reset_after
|
timedelta | None
|
Window length, anchored to first use. Mutually
exclusive with |
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
¶
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
¶
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
¶
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.
from_bucket_state
classmethod
¶
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 callbucket.calculate_retry_after's last branch makes. A duration window read back as a dripping limit would advertise a phantom one-token drip beside aretry_after_secondscomputed 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 andreset_after_secondsis 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
¶
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
¶
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
¶
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
¶
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.
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.
limit
instance-attribute
¶
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.
retry_after_seconds
instance-attribute
¶
Seconds until requested is available; 0 if not exceeded.
resets_at_ms
class-attribute
instance-attribute
¶
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.
soft
property
¶
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.
Availability¶
Availability
dataclass
¶
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.
BucketOperationResult¶
BucketOperationResult
dataclass
¶
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.
is_quota_state
property
¶
Whether this state is a quota (ADR-137): a reset schedule or a duration window.
grant_shard_count
property
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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 (
rais 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
scalewindow that shrank the numerator before// shard_countreached 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
¶
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
¶
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, |
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 ( |
None
|
AuditEvent¶
AuditEvent
dataclass
¶
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 |
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
¶
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 |
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 |
deploys_aggregator_lambda
property
¶
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
¶
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.
get_role_name
¶
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 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
¶
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
¶
Whether the backend supports audit event storage and retrieval.
supports_usage_snapshots
class-attribute
instance-attribute
¶
Whether the backend supports usage snapshot aggregation.
supports_infrastructure_management
class-attribute
instance-attribute
¶
Whether the backend supports declarative infrastructure (e.g., CloudFormation).
supports_change_streams
class-attribute
instance-attribute
¶
Whether the backend supports real-time change notifications.
supports_batch_operations
class-attribute
instance-attribute
¶
Whether the backend supports batch_get_buckets() for optimized reads.
ResourceCapacity¶
ResourceCapacity
dataclass
¶
Aggregated capacity info for a resource across entities.
EntityCapacity¶
EntityCapacity
dataclass
¶
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 |
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 |
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 |
pinc |
str | None
|
The entity incarnation stamped beside |
debited_parent_id |
str | None
|
On the warm parallel cascade path, the parent the
nested write in |
stamp_is_policy
property
¶
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).