Per-Tenant Quota Enforcement for Tile Requests

A rate limit protects capacity over seconds; a quota protects it over a month, and the two need different mechanisms. A tenant within their per-second limit can still consume a quarter of a domain’s annual serving budget in a week, and nothing in a token bucket will notice. This guide adds the second layer: a cost-weighted monthly quota per tenant, enforced per resolved domain, with the response headers that let a tenant pace themselves instead of discovering the ceiling by hitting it. It extends the cost-weighted limits in Rate Limiting Spatial API Traffic within Federated Ownership & Routing Architecture, and consumes the same weighting as Token Bucket Limits for OGC API Endpoints.

Prerequisites

Requirement Value / Assumption Notes
Tools Redis ≥ 7 (or an equivalent counter store), the gateway extension point Quota state must be shared, not per replica
Identity An authenticated tenant on every request An anonymous request cannot be quota’d
Weighting The published cost function by zoom and extent Quota in requests is quota in nothing
Ordering Quota applied after domain resolution Otherwise one domain’s burst spends another’s budget
Environment REDIS_URL, QUOTA_PERIOD Exported before running

A rate limit and a quota protect different things over different horizonsFive properties compared between a per-second rate limit and a monthly quota. The rate limit protects instantaneous capacity over seconds, is held in a token bucket, and refuses with a retry-after measured in seconds. The quota protects a budget over a calendar month, is held in a shared counter, and refuses with a retry-after measured to the period reset. The final row is the reason both exist: a tenant steadily inside their rate limit can still be the largest line on a domain bill, and no per-second mechanism can see it.Rate limitQuotaProtectsinstantaneous capacitya period budgetHorizonsecondsa calendar monthStatetoken bucketshared counterRetry-Aftersecondsto the period resetBlind tosteady over-consumptiona burst

Step-by-Step Implementation

1. Count in cost units, not requests

python
# quota.py — a monthly, cost-weighted counter per tenant per domain.
import time

ZOOM_WEIGHT = {18: 1, 16: 2, 14: 4, 12: 8, 10: 16, 8: 28, 6: 40, 3: 64}


def tile_cost(z: int) -> int:
    """Cost tracks the area a tile covers, and therefore the features the renderer
    must consider. Counting requests instead lets one tenant consume a continent's
    worth of rendering while another consumes a street corner, both 'within limit'."""
    for zoom in sorted(ZOOM_WEIGHT, reverse=True):
        if z >= zoom:
            return ZOOM_WEIGHT[zoom]
    return ZOOM_WEIGHT[3]


def period_key(tenant: str, domain: str, now: float | None = None) -> str:
    """Keyed by calendar period, so the quota resets predictably rather than on a
    rolling window a tenant cannot reason about."""
    t = time.gmtime(now or time.time())
    return f"quota:{tenant}:{domain}:{t.tm_year}-{t.tm_mon:02d}"

Verify the weighting matches the published cost function, or tenants budget against one number and are charged another:

bash
python3 -c "
import quota
for z in (18, 14, 10, 6, 3):
    print(f'z{z:>2}: {quota.tile_cost(z)} units')"

2. Charge atomically, so concurrent requests cannot overdraw

lua
-- charge.lua — atomic check-and-increment. Executed server-side in Redis so a
-- burst of concurrent requests cannot each read the same under-limit value.
-- KEYS[1] = period key, ARGV[1] = cost, ARGV[2] = limit, ARGV[3] = ttl seconds
local used = tonumber(redis.call('GET', KEYS[1]) or '0')
local cost = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])

if used + cost > limit then
  return {0, used, limit}          -- refused; nothing charged
end

local now = redis.call('INCRBY', KEYS[1], cost)
if used == 0 then
  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
end
return {1, now, limit}
python
# enforce.py — the gateway hook.
def enforce_quota(redis, tenant: str, domain: str, z: int, limits: dict) -> dict:
    cost = tile_cost(z)
    key = period_key(tenant, domain)
    allowed, used, limit = redis.evalsha(
        CHARGE_SHA, 1, key, cost, limits[tenant][domain], seconds_until_period_end()
    )
    return {"allowed": bool(allowed), "used": used, "limit": limit, "cost": cost}

Why the charge has to be atomic under concurrencyTwo concurrent requests arrive with 99 of a 100-unit quota already consumed. With a non-atomic read-then-write, both read 99, both find one unit of headroom, and both charge — leaving the counter at 101 and the quota overdrawn. With the check and increment executed as one server-side script, the first request charges to 100 and the second finds no headroom and is refused. The difference only appears under concurrency, which is why it is tested with parallel load rather than by inspection.Request ARequest BCounter storeatomic check+incrallowed, now 100atomic check+incrrefused, no headroomnon-atomic: both read 99, both charge → 101

Verify atomicity under concurrency — the check that a naive read-then-write implementation fails:

bash
# 200 concurrent requests against a limit of 100 units at 1 unit each.
redis-cli SET "quota:test:cadastre:2026-08" 0 >/dev/null
seq 200 | xargs -P 50 -I{} redis-cli --eval charge.lua "quota:test:cadastre:2026-08" , 1 100 3600 >/dev/null
redis-cli GET "quota:test:cadastre:2026-08"
# Must be exactly 100. Anything above means the charge is not atomic.

3. Report the balance on every response

A tenant who can see their consumption paces themselves; one who cannot will discover the ceiling by being refused.

python
# headers.py — the tenant's view of their own budget.
def quota_headers(result: dict) -> dict:
    remaining = max(0, result["limit"] - result["used"])
    return {
        "X-Quota-Limit": str(result["limit"]),
        "X-Quota-Used": str(result["used"]),
        "X-Quota-Remaining": str(remaining),
        "X-Quota-Cost": str(result["cost"]),        # what THIS request cost
        "X-Quota-Reset": period_end_iso(),
    }


def refusal(result: dict) -> tuple[int, dict, dict]:
    """429 for a rate limit is retryable in seconds; a quota breach is not. Saying
    so explicitly stops clients retrying at a cadence that cannot succeed."""
    body = {
        "error": "quota_exhausted",
        "used": result["used"], "limit": result["limit"],
        "resets_at": period_end_iso(),
        "guidance": "This is a period quota, not a rate limit. Retrying will not "
                    "succeed before the reset. Consider a bulk snapshot port for "
                    "large-area coverage.",
    }
    return 429, {**quota_headers(result), "Retry-After": str(seconds_until_period_end())}, body

4. Alert before the wall, not at it

yaml
# quota-alerts.yaml — a tenant approaching exhaustion is a conversation, not an incident.
groups:
  - name: tenant_quota
    rules:
      - record: tenant:quota_consumed_fraction
        expr: spatial_quota_used / spatial_quota_limit

      - alert: TenantQuotaBurnAhead
        # Consumed more than the fraction of the period elapsed, by a clear margin.
        expr: |
          tenant:quota_consumed_fraction
            > 1.3 * (time() - spatial_quota_period_start)
                  / (spatial_quota_period_end - spatial_quota_period_start)
        for: 1h
        labels: { severity: ticket }
        annotations:
          summary: "{{ $labels.tenant }} is ahead of quota pace on {{ $labels.domain }}"
          description: "Check their zoom distribution — flat coverage means a bulk extract."

Reading the burn-rate alert: consumption against elapsed periodFour tenants at day eighteen of a thirty-day period, showing the percentage of quota consumed against the sixty percent of the period that has elapsed, drawn as a dashed line. A tenant at 54 percent is pacing correctly. One at 62 is marginally ahead and not worth a conversation. One at 81 percent is well ahead and triggers the burn alert at 1.3 times elapsed. One at 97 will exhaust before the reset and should be contacted now rather than refused later.tenant A54%pacing correctlytenant B62%tenant C81%burn alert firestenant D97%contact nowperiod elapsed

Configuration Reference

Parameter Value Effect
Unit Cost-weighted tokens Requests-per-month is a limit on nothing
Period Calendar month Predictable reset a tenant can reason about
Scope Per tenant, per resolved domain One domain’s burst cannot spend another’s budget
Charge Atomic Lua script Read-then-write overdraws under concurrency
Refusal 429 + Retry-After to period end Distinguishes a quota from a rate limit
Headers Limit, used, remaining, cost, reset Lets a tenant pace itself
Burn alert 1.3× elapsed fraction Warns before the wall

Common Failure Modes & Fixes

A tenant overdraws under load. Root cause: check and increment in two round trips, so concurrent requests each see the same under-limit value. Fix: the atomic script above.

Clients retry a quota refusal every second. Root cause: an undifferentiated 429 with a short Retry-After, which is correct for a burst limit and wrong for a period quota. Fix: set Retry-After to the period reset and say so in the body.

Quota is exhausted by a single low-zoom request pattern. Root cause: correct — a zoom-3 tile costs 64 units for good reason. Fix: this is the weighting working; the tenant needs a bulk port for large-area coverage.

One domain’s burst exhausts a tenant’s whole budget. Root cause: quota applied before domain resolution, so it is estate-wide. Fix: apply after resolution and key per domain, as with rate limiting.

Quota state resets when a gateway replica restarts. Root cause: counters held in process memory. Fix: shared counter store; per-replica quota is roughly N times the intended limit and drifts with the replica count.

FAQ

Why a quota as well as a rate limit?

They protect different things over different horizons. A rate limit protects instantaneous capacity: it stops a burst from saturating renderers and degrading everyone’s latency right now. A quota protects a budget: it stops a tenant from consuming a disproportionate share of a domain’s serving cost over a month, which no per-second limit can see because the tenant is never bursting. A tenant steadily inside their rate limit can still be the largest line on a domain’s bill, and the quota is the only mechanism that makes that visible before the invoice.

Should exceeding quota block or throttle?

Block, with a clear explanation and a pointer to the right port. Throttling a quota breach keeps the tenant consuming budget they do not have while degrading their experience, which serves neither side. A clean refusal with the reset time and a suggestion — a bulk snapshot for large-area coverage, a higher tier if the need is genuine — turns the ceiling into a decision the tenant can act on. The exception is a tenant whose breach would cause real-world harm, where a temporary grant with a follow-up is better than a hard stop.

How should quotas be set initially?

From measured baselines with headroom, never from a round number. Run the weighting against a month of historical traffic per tenant, set the quota at roughly twice observed consumption, and watch the burn-rate alert rather than the wall. A quota nobody approaches is not constraining anything and will not be noticed when it should be; one that is hit routinely was set below genuine need and generates support load instead of behaviour change.

Does the quota apply to cached responses?

It should charge them, at a much lower weight. A cache hit costs the estate a lookup and egress rather than a render, so charging it the full tile weight over-penalises a well-behaved tenant with good locality. Charging it something rather than nothing keeps egress bounded and stops a tenant treating the cache as free bandwidth. Using the same cache/render split the cost attribution already records keeps the quota and the cost model consistent.

How should a quota interact with a tenant’s own SLA?

They have to be consistent, or one commitment silently voids the other. A tenant promised 99.9% availability who exhausts their quota mid-month experiences a total outage from their perspective, and pointing at the quota afterwards is a poor conversation. The workable arrangement is that the quota is sized above what the SLA’s traffic implies, with the burn-rate alert providing the warning, and that a quota breach on a tenant with an availability commitment triggers a conversation rather than an immediate hard stop. Where the two genuinely conflict, the quota is the number that should move — an SLA is a promise, and a quota is a budget control.