Market holidayJapan will be closed on 2026-11-03 (Culture Day), 5 days from now.All announcements
Article

One Account, Many Tenants: Key Isolation, Quota Allocation, and Failure Containment

Once one market data account serves multiple customers or business lines, the access layer becomes a multi-tenant system. Four isolation dimensions, a two-layer split of daily quota and RPM, pre-allocated WebSocket connection budgets, and a runnable stdlib-only tenant quota gateway.

Multi-tenant market data is the normal shape of a platform product: one company holds an account and fans it out to several customers, business lines, or teams. Past that point the access layer becomes infrastructure needing isolation across identity, capacity, and failure domain. Most teams ship v1 with one shared API key, and only discover how narrow the refactor window is when a tenant burns the quota or a key leaks. This covers four isolation dimensions, two orthogonal throttles, a runnable quota gateway, and turning X-Request-Id into a per-tenant ledger.

Four isolation dimensions

The goal is not to get requests out the door but to guarantee that no tenant's behaviour changes what another sees. That breaks into four verifiable dimensions.

DimensionMechanismSymptom when missing
IdentityOne API key per tenant, revoked independentlyOne leaked key takes every tenant offline
EntitlementKey-level scope and market enablementA lower-tier tenant reads markets it never bought
CapacityDaily quota and RPM split separately; WS budget pre-allocatedOne tenant eats the quota, everyone else gets 429
ObservabilityLog X-Request-Id and tenant per requestWhen limits trip, you cannot name the tenant

The four do not cost the same: identity and observability are convention plus middleware, capacity needs an allocation algorithm and a ledger, entitlement depends on upstream scope support.

Keys and entitlement: one key per tenant

The TIQEX authentication docs advise separate keys for dev, staging, and production, revoked the moment one leaks, and document X-API-Key as the preferred header with Authorization: Bearer accepted. Push that further and you get the multi-tenant rule: issue keys per tenant, not per environment.

Three reasons. A key is the smallest revocation unit: revoke the one that leaked, not the whole site. A key is the counting unit for throttling — RATE_LIMIT_EXCEEDED is per account, KEY_RATE_LIMIT_EXCEEDED is per key, and only the second makes "throttle one tenant without touching the rest" possible. A key is also the attachment point for entitlement and audit: API_KEY_SCOPE_DENIED means insufficient scope, MARKET_NOT_ENABLED means the plan does not cover the market, and both become tenant-scoped once keys split. IP_DENIED / GEO_DENIED adds a second axis: pin one egress IP per tenant and attribution becomes trivial. Rotation mechanics are in the REST authentication guide.

Quota allocation: two gates, three strategies

Fix the metering point first. Per the docs, REST quote and kline calls and the WebSocket handshake count toward daily quota and per-minute limits; pushed ticks are not. So what you divide is requests and handshakes, not messages. A ledger counting pushed messages will never reconcile with the bill.

Daily quota and RPM are also orthogonal: RPM governs instantaneous concurrency and interactive latency, daily quota governs cost. One tenant can saturate RPM with quota to spare, another can drain the quota at a low RPM. With one threshold, one of those shapes always gets the wrong signal.

Three allocation strategies:

  1. Hard partition — fixed shares summing to no more than the account quota. Predictable, but fragmented: idle quota held by tenant A cannot serve B.
  2. Soft partition (oversubscription) — shares sum above the account quota, betting usage curves do not line up. Cheapest, but tenants collide at peak and need queueing.
  3. Two-layer quota — reserve ten to twenty percent as emergency headroom, distribute the rest by weight, and demote a tenant past its share into a low-priority queue rather than rejecting it.

Production usually lands on the third. The point is treating the account-level cap as the last line of defence. Reference RPM tiers are 30 on Free, 120 on Standard, 600 on Pro, and 3000 on Enterprise, each with its own daily and monthly quota; check the console, and see pricing and the rate limits glossary. The gateway below keeps key routing, both gates, and 429 backoff in one place; it uses only the standard library and runs as written.

Python
# tenant_gateway.py — 多租户配额网关(仅标准库)
import json, time, urllib.error, urllib.request
from collections import defaultdict

API = "https://api.tiqex.io/api/v1/market/quote"

TENANT_KEYS = {"acme": "tk_acme_xxx", "globex": "tk_globex_xxx"}

# Daily quota and RPM are orthogonal: split separately.
TENANT_QUOTA = {"acme": {"daily": 200_000, "rpm": 200},
                "globex": {"daily": 60_000, "rpm": 60}}

_used = defaultdict(int)
_window = {}
_cooldown = {}


def gate(tenant):
    """None to pass, else the block reason."""
    now = time.time()
    if now < _cooldown.get(tenant, 0):
        return "cooldown"
    if _used[tenant] >= TENANT_QUOTA[tenant]["daily"]:
        return "DAILY_LIMIT_EXCEEDED"
    start, n = _window.get(tenant, (now, 0))
    if now - start >= 60:
        _window[tenant] = (now, 1)
    elif n + 1 > TENANT_QUOTA[tenant]["rpm"]:
        return "RATE_LIMIT_EXCEEDED"
    else:
        _window[tenant] = (start, n + 1)
    return None


def http_get(codes, api_key):
    req = urllib.request.Request(
        API + "?codes=" + codes,
        headers={"X-API-Key": api_key, "Accept": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=10) as r:
            return r.status, dict(r.headers), json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        return e.code, dict(e.headers), json.loads(e.read().decode() or "{}")


def call_quote(tenant, codes):
    reason = gate(tenant)
    if reason:
        return {"tenant": tenant, "ok": False, "reason": reason}
    status, headers, body = http_get(codes, TENANT_KEYS[tenant])
    rid = headers.get("X-Request-Id")
    if status == 429:  # per-key and per-account backoff differ
        wait = int(headers.get("Retry-After") or 1)
        _cooldown[tenant] = time.time() + wait
        return {"tenant": tenant, "ok": False, "reason": body.get("code"),
                "retryAfter": wait, "requestId": rid}
    if status >= 400 or not body.get("succ"):
        return {"tenant": tenant, "ok": False,
                "reason": body.get("code") or status, "requestId": rid}
    _used[tenant] += 1  # a WS handshake counts once too
    return {"tenant": tenant, "ok": True, "requestId": rid, "data": body.get("data")}


if __name__ == "__main__":
    # Offline self-test: stub the transport.
    http_get = lambda codes, api_key: (200, {"X-Request-Id": "req_demo"},
                                       {"succ": True, "data": [{"code": codes}]})
    for tenant in ("acme", "globex"):
        print(call_quote(tenant, "EURUSD,XAUUSD"), "daily =", _used[tenant])

Two details matter. gate() rejects over-limit calls locally, not to save quota but to protect your service: a local rejection beats an upstream 429. And only successful calls increment the ledger — but a WebSocket handshake must count once. Every handshake spends quota, and a reconnect storm drains it quietly. Track handshake frequency per tenant and refuse reconnects past a threshold.

Failure containment: one bulkhead per tenant

Allocation answers "how much does each tenant get"; containment answers "who gets hurt". The two most common incidents are tenant traffic shapes, not upstream outages: a retry storm, where a client gets a 429 and retries with no backoff; and connection exhaustion, where one tenant opens connections until the account cap is gone.

For retries, give each tenant an independent cooldown window, enforce exponential backoff with jitter, and honour Retry-After. Retry state must not be shared, or one tenant's flapping drags down everyone's throughput.

For connections, the caps are account-level: documented approximations are 1 connection and 10 symbols on Free, 3 and 25 on Standard, 10 and 50 on Pro, and 50 and 200 on Enterprise, with WS_CONN_LIMIT at the cap. Allocate the budget at startup and fail fast on oversell instead of discovering at runtime that a tenant cannot connect.

Python
# ws_budget.py — split the account-level WS caps across tenants up front
PLAN_WS = {"free": (1, 10), "growth": (3, 25), "pro": (10, 50), "enterprise": (50, 200)}


def allocate(plan, weights):
    """Raise on oversell rather than at runtime."""
    max_conn, max_sym = PLAN_WS[plan]
    total = sum(weights.values())
    out, used_conn, used_sym = {}, 0, 0
    for tenant, w in weights.items():
        conn = max(1, round(max_conn * w / total))
        sym = max(1, round(max_sym * w / total))
        out[tenant] = {"connections": conn, "symbols": sym}
        used_conn, used_sym = used_conn + conn, used_sym + sym
    if used_conn > max_conn or used_sym > max_sym:
        raise ValueError("oversold: reduce weights or upgrade the plan")
    return out


if __name__ == "__main__":
    print(allocate("pro", {"acme": 5, "globex": 3, "initech": 2}))

The last piece is the degradation ladder: live push, then delayed data with the delayed flag true, then a cached snapshot, then refusal. The hard rule is that degradation must be visible: return timestampMs and the flag with any stale value and let the caller decide. Silent degradation is indistinguishable from silent failure.

Metering and reconciliation

Isolation exists; now prove it works. Every response carries an X-Request-Id header, the only thread connecting an upstream call to a local one. A useful tenant ledger needs four columns: tenant identity, endpoint, status plus envelope code, and X-Request-Id.

Alert on the tenant dimension, not just the code. The docs recommend alerting on 401, 403, and 429 separately; aggregate by tenant too. A sudden 403 rise for one tenant usually means its plan or key scope changed; a sustained 429 rise means its share is too small or its client lacks backoff; a 401 spike in one tenant is almost always a rotation that never reached it.

Then reconcile. Set an explicit tolerance between your ledger and server-side usage; when it is exceeded, check three things: missed WebSocket reconnect handshakes, a batch job that bypassed the gateway, and health checks burning quota. Those three account for most undercounting. Size the numbers with the quota calculator before committing to allocation weights, and confirm coverage against pages such as crypto market data.

FAQ

Can one API key serve several tenants?

Technically yes, architecturally no. A shared key degrades three granularities: revocation moves from one tenant to everyone, throttling from per key to per account, and audit from which customer to which platform. Extra keys buy that isolation cheaply.

When a tenant exceeds its share, reject or degrade?

Decide by call pattern. Interactive refreshes should degrade: serve delayed data with the flag set and the experience stays continuous. Batch backfills and overnight jobs should be rejected and queued; they tolerate latency but not partial data.

Pushed ticks are not billed, so how do you meter a tenant?

Meter handshakes and subscribed symbol volume, not messages. Connections times symbols times online time is a usable proxy for internal cost allocation and tracks the connection resources consumed.

How do you migrate a tenant to a new key without downtime?

Run both keys in parallel. Grant the new key the same scope, shift traffic gradually, watch whether the tenant's 401 and 403 counts fall to zero, and revoke the old key once every X-Request-Id comes from the new one. The old key stays valid throughout, so any anomaly rolls back.

The build order is clear: split keys per tenant and complete scope entitlement, then implement the two-layer split of daily quota and RPM, then allocate the connection budget, then stand up the ledger. The first two steps are configuration plus middleware, and they downgrade a whole-site outage to a single-tenant one.

See the quota, RPM, and SLA on each plan →

Endpoint paths, error codes, rate limits, and plan tiers come from the official tiqex.io documentation (updated 2026-09-12) and pricing page, accessed 2026-09-23. Sample code illustrates engineering practice only; nothing here is investment advice.