Rate Limit Operations¶
This guide covers troubleshooting rate limit enforcement issues and procedures for adjusting limits at runtime.
Decision Tree¶
Troubleshooting¶
Symptoms¶
- Requests succeed when they should be rate limited
RateLimitExceededraised unexpectedly- Cascade to parent entity not working
- Bucket state appears incorrect
Diagnostic Steps¶
Check entity and bucket state:
# Query entity metadata
aws dynamodb get-item --table-name <name> \
--key '{"PK": {"S": "ENTITY#<entity_id>"}, "SK": {"S": "#META"}}'
# Query bucket state for a specific resource (shard 0)
# v0.9.0+ bucket key format: PK={ns}/BUCKET#{entity}#{resource}#{shard}, SK=#STATE
aws dynamodb get-item --table-name <name> \
--key '{"PK": {"S": "{ns}/BUCKET#<entity_id>#<resource>#0"}, "SK": {"S": "#STATE"}}'
Verify stored limits:
aws dynamodb query --table-name <name> \
--key-condition-expression "PK = :pk AND begins_with(SK, :sk)" \
--expression-attribute-values '{":pk": {"S": "ENTITY#<entity_id>"}, ":sk": {"S": "#LIMIT#"}}'
Unexpected RateLimitExceeded¶
Possible causes:
| Cause | Diagnosis | Solution |
|---|---|---|
| Bucket depleted | Check bucket tokens value |
Wait for refill or increase limit |
| Limit too restrictive | Compare limit capacity vs usage | Increase capacity |
| Cascade triggered | Check violations array |
Increase parent limit |
| Clock skew | Compare server time with bucket last_update |
Sync NTP |
| Over-sharded entity | shard_count above 32 on the bucket item |
See Over-sharded entities |
Check bucket state:
# v0.9.0+ bucket key format
aws dynamodb get-item --table-name <name> \
--key '{"PK": {"S": "{ns}/BUCKET#<entity_id>#<resource>#0"}, "SK": {"S": "#STATE"}}' \
--projection-expression "b_<limit_name>_tk, rf, b_<limit_name>_cp, shard_count"
Interpret the response:
tokens: Current token count (in millitokens, divide by 1000)last_update: Unix timestamp of last accesscapacity: Maximum bucket capacity (in millitokens)
Over-sharded entities¶
Before #745, the aggregator's proactive
sharding could double an entity's shard_count on light traffic, with no upper bound: one
entity went from 1 to 2048 shards over 11 aggregator invocations. Each shard holds
capacity // shard_count, so at 2048 shards almost every request is larger than one shard's
share.
Symptom: RateLimitExceeded while the entity is far below its configured limit. In one
measurement, 1 request in 200 was admitted.
How to spot it:
- The bucket item's
shard_countis above 32 (MAX_SHARD_COUNT). Use theget-itemcommand above;shard_countis in the projection. - Or the GSI3 discovery in Debug Bucket State returns more than 32 bucket items for one entity and resource.
Upgrading does not repair it. A current version never doubles past 32 and never
propagates a count above 32, but nothing lowers a shard_count that is already there. A
repair command is tracked in #747; until
then, use the manual procedure below. reset_bucket() / entity reset cannot do it: it
writes every shard in one transaction, sized for at most 32 shards.
Find every over-sharded bucket¶
aws dynamodb scan --table-name <name> \
--filter-expression "SK = :state AND shard_count > :cap" \
--expression-attribute-values '{":state": {"S": "#STATE"}, ":cap": {"N": "32"}}' \
--projection-expression "PK, shard_count"
Each PK is {ns}/BUCKET#{entity}#{resource}#{shard}; one (entity, resource) pair usually
shows up once per shard. The scan reads the whole table (about 0.5 RCU per 4 KB), so run it
once and work from its output.
Repair one entity and resource¶
Stop the clients first
Every client process caches each entity's shard_count in memory and only ever raises
it. A process still running with the old count recreates the deleted shards at that
count the next time it acquires for the entity (reproduced on moto: 20 acquires from
such a process after the repair created 20 shard items at shard_count = 2048). A
restarted process reads the count afresh.
- Upgrade first:
zae-limiter upgrade --name <name>, so the aggregator is a version that can no longer run away (#745). - Stop every process that calls
acquire()for the entity (the application, workers, anything holding aRepository). If you cannot stop them, restart all of them right after step 3 and expect to repeat it once (step 5). -
Delete every bucket item of the pair, with credentials that can delete items (
FullAccessPolicy):import boto3 from boto3.dynamodb.conditions import Key TABLE = "my-app" # the stack name NAMESPACE_ID = "..." # zae-limiter namespace show <namespace> --name <name> ENTITY = "user-123" RESOURCE = "gpt-4" table = boto3.resource("dynamodb").Table(TABLE) prefix = f"{NAMESPACE_ID}/BUCKET#{ENTITY}#{RESOURCE}#" # The shards GSI3 knows about... keys = set() query = { "IndexName": "GSI3", "KeyConditionExpression": Key("GSI3PK").eq(f"{NAMESPACE_ID}/ENTITY#{ENTITY}") & Key("GSI3SK").begins_with(f"BUCKET#{RESOURCE}#"), } while True: page = table.query(**query) keys.update(item["PK"] for item in page["Items"]) if "LastEvaluatedKey" not in page: break query["ExclusiveStartKey"] = page["LastEvaluatedKey"] # ...and every slot below the highest count, which GSI3 can lag behind shard0 = table.get_item( Key={"PK": f"{prefix}0", "SK": "#STATE"}, ConsistentRead=True, ProjectionExpression="shard_count", ).get("Item", {}) highest = max( [int(shard0.get("shard_count", 1))] + [int(pk.rsplit("#", 1)[1]) + 1 for pk in keys] ) keys.update(f"{prefix}{shard}" for shard in range(highest)) with table.batch_writer() as batch: # resends unprocessed deletes for pk in keys: batch.delete_item(Key={"PK": pk, "SK": "#STATE"}) print(f"deleted the bucket items of {highest} shard slots") -
Start the clients again. The next acquire creates the bucket at one shard, from the current config.
- Check: run the scan again. If the pair is listed, a process that was still running recreated it — restart it and repeat step 3.
On moto, a fresh process admitted 2 of 200 requests on a bucket at 2048 shards before the repair and 200 of 200 after it.
What the repair costs you. It deletes state, not config:
| Lost | Effect |
|---|---|
| Balances | The recreated bucket starts full. A rate limit can admit a burst of up to one capacity; a quota gets its whole allowance again for the current period, whatever was already spent; a session quota starts a fresh window |
| Consumption counters on the bucket items | The usage snapshots already written stay; the aggregator ignores deletes |
disabled / bypass / soft stamps, owner stamps |
Nothing: the first acquire after the repair takes the slow path, which reads disabled, bypass and soft from config (uncached) and the owner from the entity record, as for any new bucket |
Entity metadata, stored limits, and reset / top-up operation records are not touched.
Client clock skew can still double up to 32. The aggregator projects a bucket's stored
wcu balance from its rf timestamp to the aggregator's own clock before deciding to double.
rf is stamped from the client's clock, so when a client runs about 2 seconds ahead, the
projection adds no refill, a stale low balance still reads as a hot partition, and the
aggregator can double the entity again on following invocations. The walk is bounded: it stops
at MAX_SHARD_COUNT (32) and never goes past it. Keep client and Lambda clocks in sync (NTP;
Lambda's clock is already synced) to avoid it.
Limits Not Enforcing¶
Common causes:
| Cause | Solution |
|---|---|
| Entity not created | Create entity before rate limiting: await limiter.create_entity(...) |
| No stored limits configured | Set limits via set_system_defaults(), set_resource_defaults(), or set_limits() |
| Stale bucket state | Bucket refills over time; tokens may have refilled |
| Limit configuration mismatch | Verify limit capacity and refill_rate match expectations |
Verify entity exists:
aws dynamodb get-item --table-name <name> \
--key '{"PK": {"S": "ENTITY#<entity_id>"}, "SK": {"S": "#META"}}'
Resource or Entity Disabled¶
acquire() raises ResourceDisabled instead of RateLimitExceeded when the resource or
entity has been explicitly turned off (ADR-125). Unlike a throttling error, retrying will
never succeed until the disable is cleared — treat it as a 403, not a 429.
Check the resolved state:
repo = await Repository.open()
disabled, level = await repo.resolve_disabled("<entity_id>", "<resource>")
print(f"disabled={disabled} (decided at {level} level)")
level is "entity", "entity_default", "resource", or None when nothing along the walk
sets disabled explicitly (so it resolves to enabled).
Re-enable:
# Clear the resource-level disable (reverts to inherit — currently always enabled,
# since there is no system-level disable)
zae-limiter resource clear-disabled <resource>
# Or carve out a single entity while the resource stays disabled for everyone else
zae-limiter entity enable <entity_id> --resource <resource>
Because disabling stamps every existing bucket, an enable/clear-disabled call also
re-stamps eagerly before returning, so the fix takes effect on the next request, not after a
TTL or cache delay. It does not necessarily touch every bucket: resource enable/
clear-disabled skip any bucket whose entity has its own disagreeing override (that carve-out
is left alone, by design), and the unscoped entity clear-disabled (no --resource) restamps
every bucket the entity has to its own freshly re-resolved value rather than to a single
directive. See CLI: Disabling Resources and Entities
and ADR-125.
Cascade Not Working¶
If cascade to parent is not enforced:
Step 1: Verify parent entity exists:
aws dynamodb get-item --table-name <name> \
--key '{"PK": {"S": "ENTITY#<parent_id>"}, "SK": {"S": "#META"}}'
Step 2: Verify child has parent_id set:
aws dynamodb get-item --table-name <name> \
--key '{"PK": {"S": "ENTITY#<child_id>"}, "SK": {"S": "#META"}}'
# Check the "parent_id" attribute in response
Step 3: Check whether a cascade policy decides it for this resource. Cascade is decided
per resource (ADR-146): a policy on the entity's
config for the resource, then on its entity-wide (_default_) config, then on the resource
wins over the entity's own flag. get-limits / get-defaults print a Cascade: line for a
level that sets one:
zae-limiter entity get-limits <child_id> --resource <resource>
zae-limiter entity get-limits <child_id> --resource _default_
zae-limiter resource get-defaults <resource>
# "Cascade: off (explicit)" on any of these turns cascade off for this resource
Step 4: With no policy anywhere, the entity's own flag applies:
# Check the entity's cascade setting in its metadata
aws dynamodb get-item --table-name <name> \
--key '{"PK": {"S": "ENTITY#<child_id>"}, "SK": {"S": "#META"}}'
# Check the "cascade" attribute in response — should be true
To turn cascade on without recreating the entity, set a policy — for one resource, or for every resource of the entity — which restamps its existing buckets immediately:
zae-limiter entity set-cascade <child_id> on --resource <resource>
zae-limiter entity set-cascade <child_id> on
If the stack was deployed before 0.16.0, run zae-limiter upgrade first; on such a stack the
only option is to recreate the entity with create_entity(..., cascade=True).
Verification¶
Test that rate limiting is working correctly:
from zae_limiter import Repository, RateLimiter, Limit, RateLimitExceeded
repo = await Repository.open()
limiter = RateLimiter(repository=repo)
# Consume all capacity
for i in range(100):
try:
async with limiter.acquire(
entity_id="test-entity",
resource="test",
limits=[Limit.per_minute("rpm", 100)],
consume={"rpm": 1},
):
pass
except RateLimitExceeded as e:
print(f"Rate limited after {i} requests, retry_after={e.retry_after_seconds}s")
break
Procedures¶
Adjust Limits at Runtime¶
Programmatic Adjustment¶
Update stored limits for an entity without redeployment:
from zae_limiter import Repository, RateLimiter, Limit
repo = await Repository.open()
limiter = RateLimiter(repository=repo)
# Update limits for an entity
await limiter.set_limits(
entity_id="api-key-123",
resource="gpt-4", # Resource these limits apply to
limits=[
Limit.per_minute("rpm", 1000), # Requests per minute
Limit.per_minute("tpm", 100_000), # Tokens per minute
],
)
print("Limits updated successfully")
Direct DynamoDB Update¶
Advanced
Direct DynamoDB updates bypass validation. Use programmatic API when possible.
# Update a stored limit
aws dynamodb put-item --table-name <name> \
--item '{
"PK": {"S": "ENTITY#<entity_id>"},
"SK": {"S": "#LIMIT#<resource>#<limit_name>"},
"capacity": {"N": "1000000"},
"refill_amount": {"N": "1000000"},
"refill_period": {"N": "60"}
}'
Reset Bucket State¶
Give an entity its full allowance back on one resource, on every shard, now:
zae-limiter entity reset <entity_id> --resource <resource>
# or one limit only
zae-limiter entity reset <entity_id> -r <resource> --limit <limit>
The reset is written in place: the consumption counter, the disabled flag, the shard count and
the quota grant record survive, and a quota starts a new period (a session quota's window ends,
so the next request opens a fresh one). Every shard takes one slow pass on its next acquire. It
is audited as bucket_reset. See ADR-149.
Do not delete bucket items
Deleting a bucket item loses its consumption counter (usage snapshots go wrong), its
disabled stamp and, for a quota, its grant record, and a sharded quota whose shards are
not all deleted together can over-admit. Use entity reset.
Grant More Allowance¶
A purchase, a goodwill credit, or the difference after a plan upgrade:
A quota gains exactly that many tokens for the current period (stored as b_{limit}_tu); a
dripping limit gains at most the room below its ceiling. Needs Lambdas at 0.17.0 or later for a
quota. Audited as bucket_topped_up.
Debug Bucket State¶
Query all buckets for an entity using GSI3:
# Discover all bucket PKs via GSI3 (KEYS_ONLY)
aws dynamodb query --table-name <name> \
--index-name GSI3 \
--key-condition-expression "GSI3PK = :gsi3pk" \
--expression-attribute-values '{":gsi3pk": {"S": "{ns}/ENTITY#<entity_id>"}}' \
--output json
Then fetch individual bucket items using the PKs returned above.
Interpret bucket values:
All token values are stored as millitokens (multiply by 1000). Bucket attributes
use the b_{limit_name}_{field} naming convention:
| Field | Attribute | Description | Example |
|---|---|---|---|
| Tokens | b_rpm_tk |
Current available tokens × 1000 | 50000 = 50 tokens |
| Capacity | b_rpm_cp |
Base bucket size × 1000, before schedule scaling and shard division | 100000 = 100 tokens |
| Refill timestamp | rf |
Last refill (epoch ms) | 1705312800000 |
| Shard count | shard_count |
Number of shards for this bucket | 2 |
| Valid until | vu |
Next instant the effective parameters change (epoch ms). Absent means nothing here is scheduled. vu at or before now is why a bucket is taking the slow path |
1705334400000 |
| Schedule | sched |
Item-level schedule, compact encoding (leading digits are the encoding version) | 1h9-17w1-5s500 |
| Reset schedule | rsched |
Item-level reset schedule, compact encoding | 1m0h0 |
| Timezone | sched_tz |
IANA zone shared by every schedule on this item | America/New_York |
| Per-limit overrides | b_rpm_sched, b_rpm_rsched |
That limit's own schedule, when it differs from the item default. A literal - means that limit has no schedule; a missing attribute means it inherits the item default |
1h0-6c2000 or - |
| Session window start | b_session_ws |
A session quota's current window start (epoch ms). The window ends at ws + rsa × 1000 |
1705312800000 |
| Session window applied | b_session_wa |
The window start this shard's balance reflects. A ws later than wa means a new window has been opened and this shard has not yet restored its balance; an item written before the marker existed carries none, and rf is compared instead |
1705312800000 |
| Session window snapshot | b_session_wtc |
The consumption counter as it stood when another shard's new window reached this one. While the window is pending (ws later than wa), the shard restores its share less whatever it consumed since, so nothing spent in between is forgiven |
4000 |
| Session window length | b_session_rsa |
The session window's length in seconds, copied from config so readers need no config lookup | 18000 |
| Quota grant count | b_rpd_gc |
Quotas only (calendar and session): the shard count this shard's current-period share was sized at (ADR-145). The shard's ceiling is cp // gc, and its grant covers every shard j with j % gc == this shard % gc. An item written before the attribute existed carries none and reads as gc = shard_count — unless it holds more than one share at that count, in which case it is read at the smaller count whose share covers its balance (the grant that balance implies) |
2 |
| Quota top-up | b_rpd_tu |
Quotas only: allowance an operator topped this shard up by this period (millitokens, ADR-149). The shard's ceiling is cp // gc + tu; the next reset or window roll removes it |
5000000 |
The ceiling actually enforced is not b_rpm_cp. The schedule in force at the current
instant is applied to the base first, and the result is then divided by shard_count — or, for a
quota, by b_{name}_gc. An entity on a scale: 0.5 window across 2 shards admits cp × 0.5 / 2
per shard. Compare observed rejections against that, not against the stored number.
Bucket Expiry¶
Bucket items carry a DynamoDB TTL, and whether they do depends on where their limits came from:
| Limits resolved from | TTL |
|---|---|
| Entity config, for that resource | None — the item persists |
Entity config, entity-wide _default_ |
None — the item persists |
| Resource defaults | recovery_horizon × multiplier |
| System defaults | recovery_horizon × multiplier |
A limits= override passed to acquire() |
recovery_horizon × multiplier |
The multiplier is bucket_ttl_refill_multiplier, default 7; set it to 0 to disable expiry
everywhere.
For resource- and system-level limits this expiry is also the propagation mechanism: changing those defaults does not rewrite live buckets, so a bucket picks up the new numbers when it expires and is recreated. Entity-level changes fan out immediately and need no expiry.
The recovery horizon is the longest a limit could need to come back:
- A limit that drips uses time-to-fill,
(capacity / refill_amount) × refill_period_seconds, taken at its slowest across the base parameters and every schedule window. An entry that overridesrefill_amountorrefill_period_secondsto something slower therefore lengthens the TTL for the whole item. - A quota uses its reset period — the cycle its reset cron repeats on, rounded up (31 days for a monthly pattern, 366 for an annual one).
- A session quota uses its
reset_afterexactly — 5 hours for a 5-hour window, so 35 hours at the default multiplier. An expired item simply means the next request opens a fresh window, which is what an idle entity would get anyway.
Across limits sharing one item the largest horizon wins.
Verification After Changes¶
After adjusting limits, verify:
# Check available capacity
available = await limiter.available(
entity_id="<entity_id>",
resource="<resource>",
limits=[Limit.per_minute("<limit_name>", <capacity>)],
)
print(f"Available: {available}")
# Output: {'<limit_name>': <available_tokens>}
DynamoDB Key Patterns¶
| Pattern | Key | Description |
|---|---|---|
| Entity metadata | PK={ns}/ENTITY#<id>, SK=#META |
Entity configuration |
| Bucket state (v0.9.0+) | PK={ns}/BUCKET#<id>#<resource>#<shard>, SK=#STATE |
Token bucket (per shard) |
| Entity config | PK={ns}/ENTITY#<id>, SK=#CONFIG#<resource> |
Stored limit config |
| Resource config | PK={ns}/RESOURCE#<resource>, SK=#CONFIG |
Resource default limits |
| System config | PK={ns}/SYSTEM#, SK=#CONFIG |
System default limits |
Related¶
- Recovery & Rollback - Reset corrupted buckets
- Performance Tuning - Capacity planning for rate limits
- Hierarchical Limits - Cascade configuration