Deployment¶
This guide covers deploying zae-limiter infrastructure to AWS.
Overview¶
zae-limiter uses CloudFormation to deploy:
- DynamoDB Table - Stores rate limit state, entities, and usage data
- DynamoDB Streams - Captures changes for usage aggregation
- Lambda Function - Aggregates usage into hourly/daily snapshots, proactively refills token buckets for active entities, and archives audit events
- S3 Bucket - Archives expired audit events (when audit archival is enabled)
- IAM Policies - Least-privilege managed policies (AcquireOnly/FullAccess/ReadOnly)
- CloudWatch Logs - Lambda function logs
CLI Deployment (Recommended)¶
The simplest way to deploy:
# Deploy with defaults
zae-limiter deploy --name limiter --region us-east-1
# Deploy with custom settings
zae-limiter deploy \
--name limiter \
--region us-east-1 \
--log-retention-days 90 \
--pitr-recovery-days 7
CLI Options¶
| Option | Description | Default |
|---|---|---|
--name |
Resource identifier | limiter |
--region |
AWS region | boto3 default |
--endpoint-url |
Custom endpoint (LocalStack) | None |
--enable-aggregator/--no-aggregator |
Deploy Lambda aggregator | true |
--enable-provisioner/--no-provisioner |
Deploy Lambda limits provisioner | true |
--log-retention-days |
CloudWatch log retention | 30 |
--usage-retention-days |
Usage snapshot retention | 90 |
--audit-retention-days |
Audit record retention in DynamoDB | 90 |
--pitr-recovery-days |
Point-in-time recovery (1-35 days) | None (disabled) |
--enable-audit-archival/--no-audit-archival |
Archive expired audit events to S3 | true |
--audit-archive-glacier-days |
Days before Glacier IR transition | 90 |
--create-iam-roles/--no-create-iam-roles |
Create App/Admin/ReadOnly IAM roles | false |
--enable-deletion-protection/--no-deletion-protection |
Enable DynamoDB table deletion protection | false |
For the full list of options, see the CLI Reference.
Namespace Registration¶
Namespaces provide logical isolation within a single DynamoDB table. The "default" namespace is automatically registered by zae-limiter deploy, Repository.open(), and Repository.builder().build(). Application code then uses Repository.open() to connect to a registered namespace.
Deploy is per-stack, not per-namespace
The deploy command creates the underlying infrastructure (DynamoDB table, Lambda, IAM policies). Namespaces are lightweight registry records within the table — registering a new namespace does not require a new deployment.
For namespace-scoped IAM access control, see Namespace-Scoped Access Control.
Check Stack Status¶
Discover Deployed Instances¶
List all zae-limiter stacks in a region:
Output:
Rate Limiter Instances (us-east-1)
===========================================================================================
Name Status Version Lambda Schema Created
-------------------------------------------------------------------------------------------
prod-api ✓ CREATE_COMPLETE 0.2.0 0.2.0 1.0.0 2024-01-15
staging ✓ CREATE_COMPLETE 0.2.0 0.2.0 1.0.0 2024-01-10
dev-test ⏳ UPDATE_IN_PROGRESS 0.1.0 0.1.0 1.0.0 2023-12-01
Total: 3 instance(s)
⚠️ 1 instance(s) need attention
from zae_limiter import RateLimiter, LimiterInfo
# List all deployed limiters in a region
limiters = await RateLimiter.list_deployed(region="us-east-1")
for limiter in limiters:
if limiter.is_healthy:
print(f"✓ {limiter.user_name}: {limiter.version}")
elif limiter.is_failed:
print(f"✗ {limiter.user_name}: {limiter.stack_status}")
elif limiter.is_in_progress:
print(f"⏳ {limiter.user_name}: {limiter.stack_status}")
The LimiterInfo object provides:
stack_name/user_name- Full and user-friendly namesstack_status- CloudFormation statusversion/lambda_version/schema_version- Version info from tagsis_healthy/is_in_progress/is_failed- Status helpers
Delete Stack¶
Admin vs Application Workflow¶
zae-limiter supports separation of concerns between infrastructure administrators and application developers.
Admin Workflow (Infrastructure & Config)¶
Admins deploy infrastructure and configure rate limits centrally:
# 1. Deploy infrastructure
zae-limiter deploy --name prod --region us-east-1
# 2. Configure system-wide defaults
zae-limiter system set-defaults -l rpm:100 -l tpm:10000 --on-unavailable block
# 3. Configure resource-specific limits
zae-limiter resource set-defaults gpt-4 -l rpm:50 -l tpm:100000
zae-limiter resource set-defaults gpt-3.5-turbo -l rpm:200 -l tpm:500000
# 4. Configure premium user tiers
zae-limiter entity set-limits user-premium --resource gpt-4 -l rpm:500 -l tpm:500000
Application Workflow (Connect Only)¶
Application code connects to existing infrastructure without managing it:
from zae_limiter import Repository, RateLimiter
# Open repository (auto-provisions if needed)
repo = await Repository.open()
limiter = RateLimiter(repository=repo)
# Limits are automatically resolved from stored config
async with limiter.acquire(
entity_id="user-123",
resource="gpt-4",
limits=None, # Auto-resolves: Entity > Resource > System
consume={"rpm": 1},
) as lease:
await call_api()
Strict Connect (No Auto-Provisioning)¶
When infrastructure is owned entirely by your own CloudFormation, Terraform, or
CDK, use Repository.connect(). It reads only — it never creates a stack,
registers a namespace, writes the version record, or updates the Lambda, so an
application role needs no CloudFormation or Lambda permissions at all. It is also
the entry point for an application role that holds only an acquire policy:
since v0.17.0 those policies allow writes to rate-limit buckets only, so open()'s
first-time setup (namespace registration, the version record) and its Lambda
auto-update are refused for such a role. Run deploy, upgrade and
namespace register with an operator role (FullAccessPolicy /
NamespaceFullAccessPolicy) instead:
from zae_limiter import Repository, RateLimiter
# Raises rather than provisioning anything that's missing
repo = await Repository.connect("my-app")
limiter = RateLimiter(repository=repo)
# Limits are automatically resolved from stored config
async with limiter.acquire(
entity_id="user-123",
resource="gpt-4",
limits=None, # Auto-resolves: Entity > Resource > System
consume={"rpm": 1},
) as lease:
await call_api()
connect() raises instead of repairing whatever it finds missing:
| Situation | open() |
connect() |
|---|---|---|
| Table missing | Deploys the stack | InfrastructureNotFoundError |
| Namespace unregistered | Registers it | NamespaceNotFoundError |
| Version record missing | Writes it | InfrastructureNotFoundError |
| Lambda version behind client | Updates the Lambda | VersionMismatchError |
Register namespaces up front with zae-limiter namespace register (or from
your own template) so connect() can resolve them. A SyncRepository.connect()
counterpart is available with the same signature.
Benefits¶
| Concern | Admin | Application |
|---|---|---|
| Infrastructure | ✓ Deploy, update, delete stacks | Connect only |
| Rate limits | ✓ Configure at all levels | Auto-resolved |
| Credentials | Full AWS access (or FullAccessPolicy) | DynamoDB read/write only (or AcquireOnlyPolicy) |
| Changes | Through CLI/IaC | None |
This separation allows:
- Centralized control - Admins manage limits without code changes
- Simplified apps - No hardcoded limits, automatic resolution
- Audit trail - All config changes logged to DynamoDB
- Dynamic updates - Change limits without redeploying apps
See Configuration Hierarchy for limit resolution details.
Stack Lifecycle Management¶
Programmatic Cleanup¶
In addition to the CLI, you can manage stack lifecycle programmatically using the Repository's delete_stack() method:
from zae_limiter import Repository, RateLimiter
# Create repository with infrastructure
repo = await Repository.builder().build()
limiter = RateLimiter(repository=repo)
# Use the limiter...
async with limiter.acquire(
entity_id="user-123",
resource="api",
consume={"requests": 1},
) as lease:
pass
# Delete stack when done
await repo.delete_stack()
Use-Case Guidance¶
Development and Prototyping¶
For rapid iteration, declare infrastructure with cleanup:
from zae_limiter import Repository, RateLimiter
async def dev_session():
repo = await Repository.builder().enable_aggregator(False).build()
limiter = RateLimiter(repository=repo)
try:
# Development work...
pass
finally:
# Clean up development stack
await repo.delete_stack()
Production¶
For production deployments, see the Production Guide covering:
- Production checklist (PITR, alarms, SNS)
- Security best practices
- Multi-region considerations
- Cost estimation
CloudFormation Template¶
Export and customize the template:
# Export template
zae-limiter cfn-template > template.yaml
# Deploy with AWS CLI
aws cloudformation deploy \
--template-file template.yaml \
--stack-name limiter \
--parameter-overrides \
SnapshotRetentionDays=90 \
EnablePITR=true \
--capabilities CAPABILITY_NAMED_IAM
# Deploy Lambda code separately (function name is {stack-name}-aggregator)
zae-limiter lambda-export --output lambda.zip
aws lambda update-function-code \
--function-name limiter-aggregator \
--zip-file fileb://lambda.zip
Template Parameters¶
| Parameter | Description | Default |
|---|---|---|
SnapshotWindows |
Aggregation windows | hourly,daily |
SnapshotRetentionDays |
Usage data retention | 90 |
EnablePITR |
Point-in-time recovery | false |
LogRetentionDays |
CloudWatch log retention | 14 |
Programmatic Creation¶
Create infrastructure directly from your application:
from zae_limiter import Repository, RateLimiter
repo = await Repository.builder().usage_retention_days(90).build()
limiter = RateLimiter(repository=repo)
The builder declares the desired infrastructure state. CloudFormation ensures the actual infrastructure matches your declaration—creating, updating, or leaving unchanged as needed.
Deployment Options
For organizations requiring strict infrastructure/application separation, use CLI deployment or CloudFormation template export. See the Production Guide for deployment recommendations.
Infrastructure Details¶
DynamoDB Table¶
- Billing: Pay-per-request (on-demand)
- Encryption: AWS-managed keys (default)
- Streams: NEW_AND_OLD_IMAGES for Lambda trigger
DynamoDB Schema¶
| Key | Pattern | Purpose |
|---|---|---|
| PK | {ns}/ENTITY#{id} or {ns}/BUCKET#{id}#{resource}#{shard} |
Partition key |
| SK | #META, #STATE, #CONFIG#... |
Sort key |
| GSI1PK | {ns}/PARENT#{id} |
Parent lookups |
| GSI2PK | {ns}/RESOURCE#{name} |
Resource aggregation |
| GSI3PK | {ns}/ENTITY_CONFIG#{resource} or {ns}/ENTITY#{id} |
Entity configs + Bucket discovery |
| GSI4PK | {ns} |
Namespace item discovery |
Lambda Function¶
- Runtime: Python 3.12
- Memory: 256 MB
- Timeout: 60 seconds
- Trigger: DynamoDB Streams
IAM Permissions¶
The Lambda function has minimal permissions:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:Query
- dynamodb:ConditionCheckItem
When X-Ray tracing is enabled, additional permissions are granted:
AWS X-Ray Tracing¶
Enable X-Ray tracing to gain visibility into Lambda aggregator performance and troubleshoot issues.
Enabling Tracing¶
Key Details¶
| Aspect | Value |
|---|---|
| Default | Disabled (opt-in) |
| Tracing Mode | Active |
| IAM | Conditional (only when enabled) |
Why Active Mode?
The Lambda aggregator uses Active tracing mode rather than PassThrough because DynamoDB Streams do not propagate X-Ray trace context. Active mode ensures traces are always generated for Lambda invocations regardless of upstream trace headers.
What You Get¶
With X-Ray tracing enabled, you can:
- Trace Lambda cold starts - Identify initialization latency
- Monitor DynamoDB operations - See query and update times
- Debug failures - Trace errors through the processing pipeline
- Analyze performance - Find bottlenecks in stream processing
Cost Considerations¶
X-Ray charges based on traces recorded and retrieved. For typical usage:
- First 100,000 traces/month are free
- After free tier: $5.00 per million traces recorded
For high-volume deployments, consider sampling strategies or enabling tracing only for troubleshooting.
Application IAM Policies¶
The stack creates three managed IAM policies by default for different access patterns. These provide least-privilege access for applications, administrators, and monitoring systems.
Policy Summary¶
| Policy | Suffix | Use Case | DynamoDB Permissions |
|---|---|---|---|
AcquireOnlyPolicy |
-acq |
Applications calling acquire() |
GetItem, BatchGetItem, Query, ConditionCheckItem; PutItem, UpdateItem on bucket items only |
FullAccessPolicy |
-full |
Ops teams managing config | All of the above + DeleteItem, BatchWriteItem, Scan, DescribeTable |
ReadOnlyPolicy |
-read |
Monitoring and dashboards | GetItem, BatchGetItem, Query, Scan, DescribeTable |
dynamodb:ConditionCheckItem is required from v0.17.0
A bucket create is one transaction that also checks the owner entity's record
(ADR-150), so every principal that acquires — and the aggregator's role, which
pre-creates shards — needs dynamodb:ConditionCheckItem on the table. The policies
and roles this template creates grant it once the stack runs the v0.17.0 template: a
new deploy, or zae-limiter upgrade on an existing stack, which applies the new
template (and refuses a change that would replace or remove a resource). Run
zae-limiter upgrade on each stack before rolling out v0.17.0 clients; custom IAM
must add it. Without it a bucket create is denied, and acquire() raises
PermissionDenied under BLOCK and ALLOW alike
(ALLOW never covers permission errors).
AcquireOnlyPolicy gains PutItem and UpdateItem in v0.17.0
AWS authorises each Put and Update inside a TransactWriteItems by
dynamodb:PutItem / dynamodb:UpdateItem, not by TransactWriteItems
(AWS docs).
Before v0.17.0 AcquireOnlyPolicy granted neither, so a principal holding only it
(including the {stack}-app role) could not write a bucket, and
NamespaceAcquirePolicy lacked PutItem, so it could never create one (ADR-152).
The policies no longer list dynamodb:TransactWriteItems: IAM defines no such action
(service reference),
so it never granted anything.
A stack deployed earlier keeps its old policies until its template is updated:
zae-limiter upgrade applies the new template through a CloudFormation change set
(and refuses a change that would replace or remove a resource), so run it on each
stack before rolling out v0.17.0 clients. Custom IAM modelled on the old policies
must add both actions.
The acquire policies write bucket items only, from v0.17.0
AcquireOnlyPolicy and NamespaceAcquirePolicy grant PutItem and UpdateItem
only where the partition key is a bucket's (dynamodb:LeadingKeys */BUCKET#*, and
{namespace id}/BUCKET#* for the namespace policy), because every write acquire()
makes is a bucket write (#756). A principal holding only an acquire policy can no
longer write config, entity records, audit events, the namespace registry or the
version record: create_entity(), set_limits(), set_resource_defaults(),
reset_bucket(), top_up() and a first-time Repository.open() (which registers the
namespace and writes the version record) are refused with AccessDeniedException.
Those are operator actions: give them FullAccessPolicy or
NamespaceFullAccessPolicy. It is least privilege, not a boundary: a principal that
may acquire can still raise its own buckets' balances with a direct write (ADR-149).
NamespaceFullAccessPolicy gains BatchWriteItem in v0.17.0
delete_entity() deletes with BatchWriteItem, which the policy did not grant, so a
principal holding only NamespaceFullAccessPolicy got AccessDeniedException from it
(#742). dynamodb:LeadingKeys is checked on every item of a BatchWriteItem
(AWS docs),
so it can put or delete only the items the policy's PutItem and DeleteItem already
reach: the namespace's own ({id}/…), the bare namespace ID, and the shared _/ prefix
(the namespace registry). It adds no reach. Run zae-limiter upgrade on an existing
stack to apply it; custom IAM modelled on the policy must add the action.
Enabling/Disabling IAM Resources¶
# Deploy with managed policies (default)
zae-limiter deploy --name limiter --region us-east-1
# Deploy with managed policies AND IAM roles
zae-limiter deploy --name limiter --region us-east-1 --create-iam-roles
# Deploy without any IAM resources (for restricted environments)
zae-limiter deploy --name limiter --region us-east-1 --no-iam
from zae_limiter import Repository
# With managed policies only (default)
repo = await Repository.builder().build()
# With managed policies AND IAM roles
repo = await Repository.builder().create_iam_roles(True).build()
# Without any IAM resources
repo = await Repository.builder().create_iam(False).build()
Viewing Policy ARNs¶
The status command shows IAM policy ARNs:
Output includes:
IAM Policies
AcquireOnly: arn:aws:iam::123456789012:policy/limiter-acq
FullAccess: arn:aws:iam::123456789012:policy/limiter-full
ReadOnly: arn:aws:iam::123456789012:policy/limiter-read
Policy Naming¶
- Default:
${StackName}-{acq,full,read}(e.g.,limiter-acq) - With
--policy-name-format: Custom naming pattern applied to all policies
Policies respect --permission-boundary if configured.
For detailed IAM configuration and usage examples, see CloudFormation - Application IAM Policies.
Declarative Limits Management¶
Instead of configuring limits one at a time with system set-defaults, resource set-defaults, and entity set-limits, you can define all limits in a YAML manifest and apply them declaratively. A Lambda provisioner computes diffs and applies only the necessary changes.
YAML Manifest¶
Create a limits.yaml file defining your desired limit configuration:
namespace: default
system:
on_unavailable: block
limits:
rpm:
capacity: 1000
tpm:
capacity: 100000
resources:
gpt-4:
limits:
rpm:
capacity: 500
tpm:
capacity: 50000
gpt-3.5-turbo:
limits:
rpm:
capacity: 2000
tpm:
capacity: 500000
entities:
user-premium:
resources:
gpt-4:
limits:
rpm:
capacity: 1000
tpm:
capacity: 100000
Limit fields: Only capacity is required. When omitted, refill_amount defaults to capacity — or to 0 when the limit carries a reset_schedule, where a positive refill_amount is rejected — and refill_period defaults to 60 seconds. capacity is the bucket ceiling (max tokens). To customize:
limits:
rpm:
capacity: 1000 # Max tokens (bucket ceiling)
refill_amount: 100 # Refill 100 tokens per period
refill_period: 6 # Every 6 seconds (= 1000/min)
A limit may also carry a schedule (change the parameters while a cron pattern matches) or a
reset_schedule (restore the whole allowance when a window opens):
limits:
rpm:
capacity: 1000
schedule:
- cron: "* 9-17 * * MON-FRI"
tz: America/New_York
scale: 0.5
rpmo:
capacity: 1000000
reset_schedule:
- cron: "0 0 1 * *"
tz: America/New_York
Both are emitted into the generated CloudFormation template as Schedule and ResetSchedule
properties. See Declarative Limits for the full field reference.
A resource or entity-resource entry may also set disabled and cascade (true or false;
omit to inherit). cascade chooses, per resource, whether acquires also debit the parent
(ADR-146):
resources:
gpt-4:
cascade: true # model limits count against the org
limits:
tpm: {capacity: 10000}
llm:
cascade: false # the shared budget stays per user
limits:
cost: {capacity: 500}
The manifest owns both flags for the items it declares: leaving one out clears it on the next
apply. Neither is supported on system: either one there is an error. An apply restamps
existing buckets only for levels whose stored cascade changed, and limits plan warns when a
resource cascades but no entity in the manifest has its own limits for it. See
Cascade and Disabled.
Reusing Limits with YAML Anchors¶
Each level's limits replaces the levels below it — it does not merge with them (see
Configuration Hierarchy). A resource that sets only rpm is
limited on rpm only, and the system tpm no longer applies to it. To avoid repeating every
limit at every level, use YAML anchors (&name), aliases (*name) and merge keys (<<:). The
manifest is parsed with yaml.safe_load, which expands them before anything is sent to the
provisioner, so plan, apply, diff and cfn-template all see the full set at every level.
# limits-anchors.yaml
namespace: default
# Reusable pieces. Top-level keys other than namespace, system, resources
# and entities are ignored, so they can hold anchors.
x-parts:
rate: &rate {rpm: {capacity: 300}}
tokens: &tokens
tpm: &sys_tpm
capacity: 10000
schedule:
- {cron: "* 0-6 * * *", tz: America/New_York, scale: 2}
system:
limits: &sys
<<: [*rate, *tokens]
resources:
# Extend system, override one limit -> rpm 600, tpm 10000 (+ schedule)
gpt-4:
limits: &gpt4
<<: *sys
rpm: {capacity: 600}
# Leave one limit out: alias only what you keep -> rpm 300, no tpm
gpt-3.5-turbo:
limits:
<<: [*rate]
# Keep the numbers, replace only the schedule -> tpm 10000, daytime 0.5x
gpt-4o:
limits:
<<: *sys
tpm: {<<: *sys_tpm, schedule: [{cron: "* 9-17 * * 1-5", tz: America/New_York, scale: 0.5}]}
entities:
user-premium:
resources:
# Extend gpt-4, which extends system -> rpm 1000, tpm 10000 (+ schedule)
gpt-4:
limits:
<<: *gpt4
rpm: {capacity: 1000}
user-basic:
resources:
# Keep the numbers, drop the inherited schedule -> rpm 600, tpm 10000
gpt-4:
limits:
<<: *gpt4
tpm: {<<: *sys_tpm, schedule: []}
| Pattern | Spelling |
|---|---|
| Extend the level below, override one limit | <<: *sys then the limit to change |
| Extend a level that already extends another | Anchor the merged mapping (limits: &gpt4) and merge that |
| Leave one limit out | Anchor limits separately and merge only the ones to keep: <<: [*rate] |
| Keep a limit's numbers, change only its schedule | tpm: {<<: *sys_tpm, schedule: [...]} |
| Keep a limit's numbers, drop its schedule | tpm: {<<: *sys_tpm, schedule: []} |
A merge copies the whole mapping, so a nested key such as schedule replaces the inherited one
rather than adding entries to it.
Anchors save typing; they do not change resolution
Every level is still stored as a complete, independent set of limits. Changing an anchored
value takes effect everywhere it is aliased on the next limits apply — anchors are
expanded when the file is read, and live configuration does not remember where a value came
from. A level written without <<: still overrides everything below it. Anchors only work
within one file, which is also the unit limits apply manages: one manifest per namespace.
CLI Workflow¶
The typical workflow mirrors terraform plan / terraform apply:
# 1. Preview what would change
zae-limiter limits plan -n my-app -f limits.yaml
# 2. Apply the changes
zae-limiter limits apply -n my-app -f limits.yaml
# 3. Later, check for drift
zae-limiter limits diff -n my-app -f limits.yaml
plan and diff are read-only: they never deploy the stack, register the manifest's
namespace, or update the Lambdas. They exit 1 when the stack or namespace is missing, or when
the stack's Lambdas are behind the client — run zae-limiter upgrade -n my-app first. apply
registers a new namespace itself (see Read-Only Commands).
An apply writes only the entries whose stored config differs from the manifest, and fans out to live bucket items only what those writes changed, so a one-line edit to a large manifest costs one entry. Two consequences matter in operation:
- A bucket item edited out of band while its config entry still matches is not repaired by a
plain apply.
limits apply --full(orFullApply: trueon the CloudFormation resource, below) rewrites and fans out every declared entry. - Each bucket fan-out an apply owes is recorded before its config is written, run from the
table's live config, and removed once it lands. An interrupted or failed apply leaves only the
unfinished ones. A later apply retries them: at once for a fan-out that failed, and for an
interrupted apply only after the provisioner Lambda's timeout plus a minute, when that apply is
certainly gone. So even a large
--fullapply cut short by the Lambda timeout resumes where it stopped: a--full/FullApplyrun that finds work an interrupted full apply left retries that (plus whatever the manifest changed) rather than every entry, and reportsresumed. Any other unfinished fan-out never stops a full apply.plananddiffreport how many are waiting.
See Declarative Limits for the details.
The provisioner tracks which items it manages in a #PROVISIONER state record. When you remove an item from the manifest and re-apply, the provisioner deletes it from DynamoDB. Items created outside the manifest (via set-defaults or set-limits CLI commands) are not affected.
Multi-Namespace¶
Each manifest targets a single namespace. To manage limits for multiple namespaces, use separate YAML files:
# Different limits per tenant
zae-limiter limits apply -n my-app -N tenant-alpha -f alpha-limits.yaml
zae-limiter limits apply -n my-app -N tenant-beta -f beta-limits.yaml
CloudFormation Integration¶
Generate a CloudFormation template to manage limits as infrastructure-as-code:
# Generate the template
zae-limiter limits cfn-template -n my-app -f limits.yaml > limits-stack.yaml
# Deploy with AWS CLI
aws cloudformation deploy \
--template-file limits-stack.yaml \
--stack-name my-app-limits
The generated template creates a Custom::ZaeLimiterLimits resource backed by the provisioner Lambda. The Lambda ARN is imported from the main zae-limiter stack via Fn::ImportValue:
Resources:
TenantLimits:
Type: Custom::ZaeLimiterLimits
Properties:
ServiceToken: !ImportValue my-app-ProvisionerArn
TableName: my-app
Namespace: default
System:
OnUnavailable: block
Limits:
rpm:
Capacity: 1000
Resources:
gpt-4:
Cascade: true
Limits:
rpm:
Capacity: 500
disabled and cascade appear as Disabled and Cascade properties on Resources and
Entities entries; omitting one clears it, as in the manifest. A Cascade or Disabled under
System fails the stack operation.
A stack update applies only the entries that changed. Set FullApply: true on the resource
(limits cfn-template --full emits it) for the equivalent of limits apply --full.
CloudFormation calls a custom resource only when one of its properties changes, so re-running an
unchanged stack update does nothing; adding FullApply is itself the change that triggers the
full apply. While it stays set, every update is a full one. Any value other than true or
false fails the stack operation before anything is written.
This approach lets you manage limits alongside other infrastructure in CloudFormation, with full lifecycle support (Create, Update, Delete).
A partial apply succeeds; it does not roll the stack back
The provisioner commits config writes before fanning them out to live bucket items, so
once a fan-out runs something is already in the table and a clean failure is no longer
available. A fan-out that fails part-way therefore returns SUCCESS, with the failures
listed under errors in the custom resource's response data, rather than FAILED — a
rollback would revert a stack whose configuration had already been applied.
A successful stack update is therefore not proof that every bucket was reached. Check
errors in the response data, or the {stack}-limits-provisioner log group, which records
the same list. Every write is idempotent, and a failed fan-out is retried by the next
update; since an unchanged stack update never reaches the resource, add or toggle
FullApply to run one. The #PROVISIONER record is written on both outcomes, so it always describes the
config that is actually in the table.
A failure writing that record is reported as FAILED: it is the last step, nothing after it can be salvaged, and a retry is the right answer.
Provisioner Architecture¶
The provisioner is a Lambda function ({stack}-limits-provisioner) that:
- Receives either a CLI event (action + manifest) or a CloudFormation custom resource event
- Reads the previous managed state from the
#PROVISIONERDynamoDB record - Computes a diff between the manifest and previous state (create/update/delete changes)
- Applies changes via DynamoDB PutItem (create/update) and DeleteItem (delete)
- Updates the
#PROVISIONERrecord with the new managed set and a SHA-256 hash of the applied manifest
The provisioner uses the same DynamoDB config records (system #CONFIG, resource #CONFIG, entity #CONFIG#{resource}) as the imperative API, so limits set declaratively are immediately visible to acquire() calls.
Imperative vs Declarative¶
| Aspect | Imperative | Declarative |
|---|---|---|
| Commands | system set-defaults, resource set-defaults, entity set-limits |
limits plan, limits apply |
| State tracking | None (each command is independent) | #PROVISIONER record tracks managed items |
| Drift detection | Manual comparison | limits diff |
| Bulk changes | Multiple commands | Single YAML file |
| CFN integration | Not available | limits cfn-template |
| Best for | Ad-hoc changes, interactive use | Reproducible config, CI/CD pipelines |
Both approaches write to the same DynamoDB config records and can coexist. However, mixing imperative and declarative management for the same items may cause the provisioner to overwrite manual changes on the next apply.
Rolling Upgrades¶
Resource and entity disable is enforced client-side¶
Disabling a resource or entity (ADR-125) works by
stamping a disabled attribute onto each bucket item. Enforcement lives in the
client's conditional write — attribute_not_exists(disabled) in the speculative
UpdateItem — not in DynamoDB itself, and not in the aggregator Lambda.
During a rolling upgrade this means a client still running a version older than v0.12.0 does not carry that condition, and will keep admitting requests for a resource or entity that a newer client has disabled. Nothing is lost: the stamp is already written, and takes effect for each process as it upgrades. But the disable is not fleet-wide until every client is upgraded.
Treat a disable as effective only once the rollout completes. If you are relying on it as an access-control boundary rather than an operational switch, finish upgrading all clients first.
Schedules and quotas are enforced client-side¶
A schedule (ADR-135) rides on the bucket item as sched,
rsched and sched_tz, with vu naming the next instant at which the effective parameters
change. Enforcement is again the client's conditional write —
attribute_not_exists(vu) OR vu > now — and only a client writes those attributes.
A client older than v0.14.0 has two effects during a rollout. It does not carry the vu
condition, so it keeps spending tokens that were minted under a window which has since closed.
And any bucket it creates carries no schedule at all, so the aggregator refills that bucket
toward the base ceiling until an entity-level change fans out or the bucket's TTL expires.
Treat a schedule or a calendar quota as effective only once every client is past v0.14.0.
Next Steps¶
- Production - Production checklist, security, cost estimation
- CloudFormation - Template details
- Monitoring - Dashboards, alerts, Logs Insights
- Namespace Keys Migration - Migrating to namespace-prefixed keys
- LocalStack - Local development setup