Skip to content

Per-Resource Limits and a Shared Budget

A worked example that puts several features together. A multi-tenant service has many metered resources (endpoints, products, regions — often 30 or more) and wants, for every user:

Need How Section
Limits per resource, on more than one dimension (requests and units of work) One resource per metered resource, several limits each Resources and the budget
A shared budget across every resource, each with its own price A budget resource with a weekly and a session quota, debited in the same call Resources and the budget
Plans (free, pro, enterprise), with upgrades, purchases and session resets taking effect at once Free plan as resource defaults, paid plans as entity config, top_up() and reset_bucket() Plans
An organisation limit on the resources, but not on the budget User → org parent, cascade on per resource Organisations
Usage per organisation, optionally capped in real time Usage summaries; an opt-in cascade on the budget Roll-up per organisation
A non-enforcing rollout Soft limits and bypass Rolling out without enforcing

Each feature has its own page; this one shows how they fit and what a call costs.

Resources and the budget

Give every metered resource its own resource name and its own limits. The budget is one more resource, budget, holding two quotas: a weekly allowance that comes back every Monday, and a session allowance that comes back five hours after each user's own first use (session quotas).

Budget amounts are integers, so pick a small unit: micro-USD below, so $5.00 is 5_000_000. The price table lives in your application; the limiter only sees the result.

from datetime import timedelta

from zae_limiter import Limit

# The free plan: resource defaults, so every user without a plan of their own gets them
await limiter.set_resource_defaults(
    "search", [Limit.per_minute("requests", 60), Limit.per_minute("units", 10_000)]
)
await limiter.set_resource_defaults(
    "translate", [Limit.per_minute("requests", 30), Limit.per_minute("units", 5_000)]
)
await limiter.set_resource_defaults(
    "budget",
    [
        Limit.quota("weekly", 5_000_000, cron="0 0 * * 1"),  # $5 a week, back every Monday
        Limit.quota("session", 1_000_000, reset_after=timedelta(hours=5)),  # $1 a session
    ],
)

# Prices in micro-USD per request and per unit of work: application data, not limiter config
PRICES = {
    "search": {"requests": 2_000, "units": 50},
    "translate": {"requests": 1_000, "units": 20},
}


def cost(resource: str, usage: dict[str, int]) -> int:
    price = PRICES[resource]
    return sum(price[name] * amount for name, amount in usage.items())


await limiter.create_entity(entity_id="user-1")

estimate = {"requests": 1, "units": 100}
price = cost("search", estimate)  # 7,000 micro-USD
async with limiter.acquire(
    "user-1",
    "search",
    consume=estimate,
    also={"budget": {"weekly": price, "session": price}},
) as lease:
    actual = {"requests": 1, "units": 140}  # known once the work is done
    extra = cost("search", actual) - price
    await lease.adjust(units=actual["units"] - estimate["units"])  # search
    await lease.resource("budget").adjust(weekly=extra, session=extra)
    print(lease.resource("budget").consumed)  # {'weekly': 9000, 'session': 9000}

One acquire() debits the resource and the budget, and admits both or neither: if either one is short, nothing is debited and RateLimitExceeded names the limit that was (Several Resources per Acquire). The two adjust() calls are written together when the block exits, one write per bucket.

Both quotas take the same amount: every spend counts against the week and the session alike.

Plans

The free plan is the resource defaults above. A paid plan is entity config: one set_limits() per resource the plan changes, which outranks the resource defaults for that user (Configuration Hierarchy). Keep the plan table in your application, the same way as the prices:

from datetime import timedelta

from zae_limiter import Limit

await limiter.set_resource_defaults(
    "budget",
    [
        Limit.quota("weekly", 5_000_000, cron="0 0 * * 1"),
        Limit.quota("session", 1_000_000, reset_after=timedelta(hours=5)),
    ],
)

PLANS = {
    "pro": {
        "search": [Limit.per_minute("requests", 600), Limit.per_minute("units", 100_000)],
        "budget": [
            Limit.quota("weekly", 50_000_000, cron="0 0 * * 1"),
            Limit.quota("session", 10_000_000, reset_after=timedelta(hours=5)),
        ],
    },
}


await limiter.create_entity(entity_id="user-2")

# Upgrade: the plan's limits on every resource it changes. A quota's new
# allowance starts at its next reset; resetting it starts the plan today.
for resource, limits in PLANS["pro"].items():
    await limiter.set_limits("user-2", limits, resource=resource)
await repo.reset_bucket("user-2", "budget")

# A purchase: $20 more this week; the order id makes a retry safe
await repo.top_up("user-2", "budget", {"weekly": 20_000_000}, idempotency_key="order-1001")

# Support resets the session
await repo.reset_bucket("user-2", "budget", limits=["session"])

# Downgrade: back to the resource defaults
for resource in PLANS["pro"]:
    await limiter.delete_limits("user-2", resource=resource)
availability = await limiter.check_availability("user-2", "budget")
print(availability.available["weekly"])  # 25000000: the free $5 plus the $20 purchase

Every change takes effect on the user's next call:

Event Calls What the user sees
Upgrade set_limits() per resource; reset_bucket() on the quota resource to start the new allowance today New ceilings at once. A dripping limit refills toward its new ceiling at the new rate. A quota never drips: it keeps this period's remaining balance and gets the new allowance at its next reset, or at once with reset_bucket(), which also forgets this period's spend
Downgrade delete_limits() per resource (back to free), or set_limits() to a smaller plan Every balance is cut to the new ceiling on the next call. What was spent this period is not recounted: a user with $40 left on pro keeps $5, the free ceiling. Purchases stay on top, until the quota's next reset
Purchase top_up(..., idempotency_key=...) Exactly that much more, above the plan, until the quota's next reset

Do not upgrade a plan with top_up()

A top-up is a purchase: it lasts until the quota's next reset whatever the plan, and nothing takes it back. Topping up the difference between two plans therefore survives a downgrade in the same period — upgraded and downgraded within one week, a user would keep the whole pro allowance until Monday. Change the plan with set_limits(), and use reset_bucket() when the new allowance should start at once.

| Session reset | reset_bucket(..., limits=["session"]) | The session ends; the next call opens a fresh one at its full allowance |

top_up() and reset_bucket() change the balance on every shard in one transaction and record an audit event; see Reset and Top-Up for how each limit shape responds and why a purchase should carry an idempotency key. A dripping limit gains at most its room below the ceiling from top_up(); use reset_bucket(..., limits=["requests"]) to fill one at once.

Many users, few plans

Plans are set per user from your application. A limits manifest suits the parts that change rarely — resource defaults and organisations — rather than thousands of users.

Organisations

Make each organisation a parent entity and each user its child. A cascade policy per resource decides where the organisation is debited too (Cascade per Resource):

  • the metered resources cascade: the organisation's limits protect a shared upstream quota;
  • the budget does not: it is per user, and debiting the organisation would cost a write per call for nothing.
await limiter.set_resource_defaults(
    "search", [Limit.per_minute("requests", 60), Limit.per_minute("units", 10_000)]
)
await limiter.set_resource_defaults("budget", [Limit.per_day("cents", 500)])

await limiter.create_entity(entity_id="org-acme")
await limiter.create_entity(entity_id="user-3", parent_id="org-acme")  # no cascade=True needed

# Metered resources count against the org; the budget stays per user
await repo.set_resource_cascade("search", True)
await repo.set_resource_cascade("budget", False)

# The org's own limits, wherever cascade is on: these are what a cascade debits.
# Only the shared upstream quota; requests stay a per-user limit.
await limiter.set_limits("org-acme", [Limit.per_day("units", 1_000_000)], resource="search")

async with limiter.acquire(
    "user-3", "search", consume={"requests": 1, "units": 100}, also={"budget": {"cents": 1}}
):
    pass

org = await limiter.check_availability("org-acme", "search")
print(org.available)  # about {'units': 999900}: debited on search, and only on units

The organisation is debited only on the limits it declares itself, so give it just the ones that protect something shared.

The organisation needs its own limits wherever cascade is on

A cascade debits the parent against the parent's own limits for that resource. An organisation with none falls back to the resource defaults — one free user's numbers — and the whole organisation is then limited to one free user's allowance. Set them with set_limits(org, ..., resource=...) for every cascading resource, or in the manifest; limits plan warns when a manifest turns cascade on and no entity has its own limits there.

Moving a user to another organisation is one call, and takes effect on the user's next call in every process (Moving an Entity):

await limiter.create_entity(entity_id="org-north")
await limiter.create_entity(entity_id="org-south")
await limiter.create_entity(entity_id="user-4", parent_id="org-north")

await limiter.set_parent("user-4", "org-south")

Usage already charged to the old organisation stays there.

Roll-up per organisation

Usage per organisation comes from usage snapshots, written by the aggregator Lambda. On a cascading resource the organisation is debited on every call, so it has snapshots of its own. The budget does not cascade, so add up the users':

from zae_limiter import Limit

await limiter.create_entity(entity_id="org-acme")
await limiter.create_entity(entity_id="user-5", parent_id="org-acme")

# Cascading resource: the org's own snapshots
search = await limiter.get_usage_summary(entity_id="org-acme", resource="search")

# Per-user budget: the sum of the users' snapshots
weekly_spend = 0
for user in await limiter.get_children("org-acme"):
    summary = await limiter.get_usage_summary(entity_id=user.id, resource="budget")
    weekly_spend += summary.total.get("weekly", 0)

Snapshots are hourly and daily, and arrive after the aggregator processes the stream: good for billing and dashboards, not for stopping a request.

A real-time cap on the organisation's budget is opt-in: turn cascade on for budget and give the organisation budget limits of its own. Every call then also debits the organisation's budget bucket, +1 write at admission and +1 at reconcile (+2 WCU per call):

from zae_limiter import Limit

await limiter.set_resource_defaults("budget", [Limit.quota("weekly", 5_000_000, cron="0 0 * * 1")])
await limiter.create_entity(entity_id="org-acme")
await limiter.create_entity(entity_id="user-6", parent_id="org-acme")

await repo.set_resource_cascade("budget", True)
await limiter.set_limits(
    "org-acme", [Limit.quota("weekly", 500_000_000, cron="0 0 * * 1")], resource="budget"
)

The organisation's budget bucket is then written by every user, so read Gotchas on hot organisation buckets first.

Rolling out without enforcing

Soft limits and bypass let you meter before you enforce:

  • Soft is per limit: introduce the budget with soft=True, watch who would have been rejected (lease.resource("budget").overdrawn, UsageSnapshot.overdrawn), then store it again without soft to enforce it. The other limits stay hard throughout.
  • Bypass is per entity and resource: bypass_entity("user-7", resource="budget") waives one user's budget (an incident, a VIP), while their per-resource limits still apply. clear_entity_disabled() lifts it.
from zae_limiter import Limit

await limiter.set_resource_defaults(
    "budget", [Limit.quota("weekly", 5_000_000, cron="0 0 * * 1", soft=True)]
)
await limiter.create_entity(entity_id="user-7")

async with limiter.acquire(
    "user-7", "search", consume={"rpm": 1}, also={"budget": {"weekly": 9_000_000}}
) as lease:
    print(lease.resource("budget").overdrawn)  # ['weekly']: admitted, and over the allowance

Both keep counting consumption, so usage snapshots stay exact.

Cost per call

One call debits the resource for the user and the organisation, and the budget for the user — three bucket items, written together on the fast path. Writes are $0.625 per million (Performance).

Call Admit Reconcile (adjust() once usage is known) Total Per million calls
Resource cascades, budget per user 3 WCU, 1 round trip 3 WCU, 1 round trip 6 WCU $3.75
…with a real-time organisation budget cap 4 WCU 4 WCU 8 WCU $5.00
…when the estimate was exact (no adjust) 3 WCU 0 3 WCU $1.88

No reads on the warm path: the fast path writes without reading configuration, and learns the cascade policy and the parent from the bucket items its writes return. A user's first call (every resource on the slow path), a rejection, and a rejection the client already knows about are priced in Several Resources per Acquire.

Gotchas

  • Organisation buckets are hot. Every user's call writes the organisation's bucket on each cascading resource. When one bucket item takes more than about 1,000 writes a second it is split into shards automatically, up to 32 (Write Sharding). Each shard holds capacity // shard_count, so a single request larger than one shard's share is rejected even while the organisation is under its limit: an organisation with units: 1,000,000/day at 32 shards admits at most 31,250 units in one call (#475). Size organisation limits with that in mind, or split large requests.
  • Two levels only. A user has one parent, which has none. To report on a level above the organisation (a region, a reseller), roll up the organisations' usage snapshots; it cannot also be enforced in the same call.
  • One resource per metered resource, not one item. With 30 resources, do not fold them into one resource carrying 60 limits (search_requests, translate_requests, …). Every limit of a resource lives on one bucket item, so every call would write to one large item — a write to an item over 1 KB costs more than one WCU — and every call, whatever it used, would contend for that one item. A resource each means a call writes only what it uses.
  • At most 16 resources per call, the primary included. A call that uses one metered resource and the budget needs two.
  • Limits are replaced, not merged. set_limits() replaces a level's whole list, so a plan entry names every limit the plan sets for that resource (Replacement Semantics).
  • Version. top_up(), reset_bucket(), set_parent() and soft limits need a stack whose Lambdas are 0.17.0 or later; run zae-limiter upgrade if a call is refused with VersionMismatchError.

A manifest

The resource defaults and organisations change rarely, so they suit a limits manifest applied with zae-limiter limits apply. YAML anchors keep 30 resources short: the provisioner reads only namespace, system, resources and entities, so a top-level x-… key is a place to define them.

namespace: default

x-shapes:
  standard-free: &standard-free
    requests: {capacity: 60}
    units: {capacity: 10000}
  standard-org: &standard-org   # the shared upstream quota only
    units: {capacity: 1000000, refill_period: 86400}

resources:
  search:
    cascade: true
    limits: *standard-free
  translate:
    cascade: true
    limits: *standard-free
  summarize:
    cascade: true
    limits:
      <<: *standard-free      # the standard shape, one limit changed
      units: {capacity: 2000}
  budget:
    cascade: false
    limits:
      weekly:
        capacity: 5000000
        reset_schedule: [{cron: "0 0 * * 1", tz: UTC}]
      session:
        capacity: 1000000
        reset_after_seconds: 18000

entities:
  org-acme:
    resources:
      search: {limits: *standard-org}
      translate: {limits: *standard-org}
      summarize: {limits: *standard-org}

zae-limiter limits plan -f limits.yaml shows what would change before you apply it. A paid plan per user can be written the same way under entities, but with many users it is usually simpler to call set_limits() from your application when the plan changes.

Reference

Task Python CLI
Free plan (resource defaults) limiter.set_resource_defaults() zae-limiter resource set-defaults
Paid plan (entity config) limiter.set_limits() / delete_limits() zae-limiter entity set-limits / delete-limits
Debit a resource and the budget limiter.acquire(..., also=...), lease.resource() — (runtime only)
Purchase repo.top_up() zae-limiter entity top-up
Reset a session, or start a new plan's quotas today repo.reset_bucket() zae-limiter entity reset
Organisation limiter.create_entity(parent_id=...) zae-limiter entity create --parent
Cascade per resource repo.set_resource_cascade(), repo.set_entity_cascade() zae-limiter resource set-cascade
Move a user limiter.set_parent() zae-limiter entity set-parent
Usage per organisation limiter.get_usage_summary(), limiter.get_children() zae-limiter usage summary
Soft limit Limit.*(..., soft=True) --soft NAME
Bypass repo.bypass_entity(), repo.bypass_resource() zae-limiter entity bypass

The full signatures are in the RateLimiter and Repository API reference.