Skip to content

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

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.

# Register additional namespaces
zae-limiter namespace register tenant-alpha --name limiter
zae-limiter namespace register tenant-beta --name limiter

# List namespaces
zae-limiter namespace list --name limiter

# Show namespace details
zae-limiter namespace show tenant-alpha --name limiter
from zae_limiter import Repository, RateLimiter

# Connect to a specific tenant namespace
repo = await Repository.open("tenant-alpha")
limiter = RateLimiter(repository=repo)

# Register additional namespaces
await repo.register_namespace("tenant-beta")

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

zae-limiter status --name limiter --region us-east-1
from zae_limiter import Repository

repo = await Repository.open()

available = await repo.ping()  # Returns True if DynamoDB is reachable

if available:
    print("Stack is ready")
else:
    print("DynamoDB not reachable")

For comprehensive status including CloudFormation details, use the CLI command.

Discover Deployed Instances

List all zae-limiter stacks in a region:

zae-limiter list --region us-east-1

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 names
  • stack_status - CloudFormation status
  • version / lambda_version / schema_version - Version info from tags
  • is_healthy / is_in_progress / is_failed - Status helpers

Delete Stack

zae-limiter delete --name limiter --region us-east-1 --yes

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:

- xray:PutTraceSegments
- xray:PutTelemetryRecords

AWS X-Ray Tracing

Enable X-Ray tracing to gain visibility into Lambda aggregator performance and troubleshoot issues.

Enabling Tracing

zae-limiter deploy --name limiter --region us-east-1 --enable-tracing
from zae_limiter import Repository, RateLimiter

repo = await Repository.builder().enable_tracing(True).build()
limiter = RateLimiter(repository=repo)

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:

zae-limiter status --name limiter --region us-east-1

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 (or FullApply: true on 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 --full apply cut short by the Lambda timeout resumes where it stopped: a --full / FullApply run that finds work an interrupted full apply left retries that (plus whatever the manifest changed) rather than every entry, and reports resumed. Any other unfinished fan-out never stops a full apply. plan and diff report 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:

  1. Receives either a CLI event (action + manifest) or a CloudFormation custom resource event
  2. Reads the previous managed state from the #PROVISIONER DynamoDB record
  3. Computes a diff between the manifest and previous state (create/update/delete changes)
  4. Applies changes via DynamoDB PutItem (create/update) and DeleteItem (delete)
  5. Updates the #PROVISIONER record 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