Tutorial: Multi-Tenant Setup
On this page
This tutorial shows how to register tenants with explicit quotas, understand what happens for unregistered tenants, and use the opt-in shared-cache feature.
1. Explicit registration
from zerda.cache import CacheManager, CacheConfig, TenantRegistration
manager = CacheManager(CacheConfig())
manager.register_tenant(TenantRegistration(
tenant_id="acme-corp",
display_name="Acme Corp",
memory_quota_mb=1024,
max_entries=50_000,
request_quota_rpm=600,
allow_shared_read=False,
allow_shared_write=False,
))
manager.register_tenant(TenantRegistration(
tenant_id="beta-inc",
display_name="Beta Inc",
memory_quota_mb=256,
max_entries=5_000,
request_quota_rpm=100,
))
Every CacheManager also has a built-in "default" tenant, registered automatically from
CacheConfig.tenant's defaults — you don't need to register it yourself, and (per
TenantManager.deregister) you cannot remove it.
2. What happens for tenants you never registered
result = manager.get_sync("hello", tenant_id="never-seen-before")
TenantManager._get_state auto-registers "never-seen-before" the first time it's used,
under TenantConfig's default quota values (not "acme-corp"'s or "beta-inc"'s) — see
tenant_manager.md.
This means typos in a tenant_id string silently create a new, quota-limited tenant rather
than raising an error — worth guarding against at your application's boundary if tenant IDs
come from user input.
3. Quota enforcement in practice
from zerda.cache.tenant_manager import QuotaExceeded
# Exhaust beta-inc's 100 requests/minute quota in a tight loop:
for i in range(150):
result = manager.get_sync(f"query {i}", tenant_id="beta-inc")
# get_sync() does NOT raise QuotaExceeded itself — CacheManager.get() catches it
# internally and returns a miss. To observe the quota being hit directly, call
# the TenantManager yourself:
try:
manager._tenant_mgr.check_and_charge_request("beta-inc")
except QuotaExceeded as e:
print(e.reason)
Note that CacheManager.get()/put() swallow QuotaExceeded and return a plain miss
(hit=False) or None respectively — see
cache_manager.md.
If your application needs to distinguish "quota exceeded" from "genuine cache miss" to show
a different message to the caller, you'll need to call TenantManager methods directly (as
above) or track get_metrics()["errors"]["quota_exceeded"] counts, since that distinction
isn't exposed on CacheLookupResult itself.
4. Shared cache
manager.register_tenant(TenantRegistration(
tenant_id="acme-corp",
allow_shared_read=True,
allow_shared_write=True,
))
# acme-corp publishes a shared answer:
await manager.put(
"What is your refund policy?",
"Standard 30-day refund policy applies.",
tenant_id="acme-corp",
is_shared=True,
)
# beta-inc can read it ONLY if beta-inc itself has allow_shared_read=True:
manager.register_tenant(TenantRegistration(tenant_id="beta-inc", allow_shared_read=True))
result = await manager.get("What is your refund policy?", tenant_id="beta-inc")
Whether beta-inc's lookup actually reaches acme-corp's shared entry depends on it being
found via the exact-match or semantic-search path — sharing does not create a
tenant-independent lookup; it only relaxes the access check
(TenantManager.is_accessible) once
a candidate entry has otherwise been located.
Related reading
tenant_manager.md— full API reference- Known Issues —
a caveat about
flush_tenant()also deregistering tenants under some conditions