The gateway rate limiter's counters are per-instance in-memory: N workers means N times the intended limit, and a restart resets every tenant's budget to full
[major] operations
Fields
id
gotchas/ops-wasm-ratelimit-state-is-per-worker-and-lost-on-restartClass
operations
Severity
major
Concepts
ratelimitmulti-tenantauthnkubernetesstate
Symptom
Tenant rate limits drift upward under load and reset to nothing on deploys. A tenant configured for 60 requests/minute sustains several hundred per minute when the gateway has several workers, and every rolling restart hands every tenant a fresh full budget, so a tenant that has been correctly throttled all morning is throttlable again immediately after the next rollout. Because the limit is enforced per worker rather than globally, the error rate for a well-behaved tenant is not reproducible across gateway replicas.
Root cause
The middleware's own Limitations section lists it: 'Per-instance state (not shared across workers)', 'No cross-process state sharing', 'Memory growth with unique identifiers', 'State lost on instance restart', and the Overview explains why: 'Since WASM components are stateless, each worker thread maintains its own counter. For production, implement rate limiting at the router/host level with shared state.' The default example enforces 60 requests per minute per identifier, with identifier priority API Key then IP Address then Request ID — three different failure modes stacked on one scheme: an API-key identifier is per-worker-budgeted; an IP identifier fails outright behind NAT or a proxy (so all tenants behind one egress share one budget); and a Request ID identifier is per-request, so the 'limit' is meaningless because a client chooses it. 'Memory growth with unique identifiers' is the mirror problem: an identifier derived from anything high-cardinality grows gateway memory without bound.
Workaround
Move the counter out of the guest module into shared state, which is what the docs prescribe, and enforce at the router or host level. Practically: put the limit in an external store (Redis or the platform's rate-limit service) keyed on a stable tenant identity derived from the API key, not IP and not a request id; make the gateway a single logical enforcer even with multiple replicas (an EPP active-active deployment shares no local state, so an in-memory counter there has the same defect). Choose the identifier deliberately: API key is the only one of the three defaults that identifies a tenant. If you cannot add shared state, treat the in-module limit as a coarse per-replica circuit breaker and size it at roughly limit/replicas — understanding that restarts restore full budget. Note the sibling auth example has the same shape: a single EXPECTED_API_KEY compiled into the guest module, which means rotation is a redeploy, not a config change.
notes
The generalisation: any rate limit enforced in a stateless or per-instance component is not a quota. This is not specific to WASM middleware — the same applies to any in-process limiter behind a load balancer with N replicas, where the effective global limit is N times the configured value. The honest state of the art for LLM serving is that per-tenant *compute* quotas are not solved at any layer (see ops-ratelimit-token-vs-request-count); what exists is per-tenant request rate, and it must be implemented in shared state to mean anything. SCHEME RECLASSIFIED (2026-10-04): was 'config'.
Notes
The generalisation: any rate limit enforced in a stateless or per-instance component is not a quota. This is not specific to WASM middleware — the same applies to any in-process limiter behind a load balancer with N replicas, where the effective global limit is N times the configured value. The honest state of the art for LLM serving is that per-tenant *compute* quotas are not solved at any layer (see ops-ratelimit-token-vs-request-count); what exists is per-tenant request rate, and it must be implemented in shared state to mean anything. SCHEME RECLASSIFIED (2026-10-04): was 'config'.