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 |
Step-by-Step Implementation
1. Count in cost units, not requests
# 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:
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
-- 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}
# 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}
Verify atomicity under concurrency — the check that a naive read-then-write implementation fails:
# 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.
# 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
# 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."
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.
Related
- Rate Limiting Spatial API Traffic — the parent topic and the cost weighting
- Token Bucket Limits for OGC API Endpoints — the per-second layer this sits above
- Attributing Tile Serving Cost to Consumers — where quota consumption meets real cost