5.9 KiB
Redis Distributed Rate-Limit Increment Design
Status: implemented as standalone R1
Parent: 2026-07-26-redis-production-capability-design.md §§19–21
Goal and readiness
Provide three selectable, bounded distributed rate-limit algorithms:
- fixed window;
- sliding-window counter;
- token bucket.
This increment is a standalone Redis R1 provider. It does not claim R2 topology/security/failover qualification and does not implement sliding log, GCRA, leaky bucket, evaluation dedup, hierarchical all-or-nothing policies or local emergency fallback.
Ownership
shared-contractowns the edge-enforcement semantic port and provider-neutral request, policy, decision and failure outcomes. Business quotas remain application use-case policy and do not use this port.adapter:outbound:cache-redisowns Redis keys, atomic Lua programs, structured reply parsing, failure certainty and the provider implementation.app-bootstrapowns the explicit provider/policy selection.- The existing inbound-web local limiter remains a compatibility path until a separate inbound migration. Its types do not cross into the Redis provider.
The rate-limit runtime does not reuse app.cache.redis, the cache connection or cache fail-open
decorators. Coordination has different failure and deployment semantics.
Shared semantic contract
EdgeRateLimitPort.evaluate(RateLimitRequest) accepts:
- bounded
policyId; - already pseudonymized/bounded
subjectDigest; - positive request cost;
- optional evaluation ID (rejected in this non-deduplicating revision);
- finite caller deadline.
RateLimitPolicy freezes policy ID/revision, one algorithm-specific parameter subtype, maximum
cost, cleanup grace, maximum clock regression and FAIL_CLOSED. Construction rejects mismatched
algorithm/parameters, arithmetic outside Lua's exact integer range and unsupported failure/dedup
claims.
The outcome is one of:
Evaluated(decision);Unavailable(policyId, retryAfter, category)for known pre-send/no-mutation failures and unsafe server clock;Indeterminate(policyId, retryAfter)for post-dispatch uncertain mutation;Incompatible(policyId, category)for state/program/reply mismatch.
RateLimitDecision includes allow/deny, limit, remaining, retry-after, reset-at, policy ID/revision,
GLOBAL_REDIS source and certainty. Fixed window and token bucket are CERTAIN;
sliding-window counter is APPROXIMATE_ALGORITHM.
Atomic programs
Each v1 program uses one versioned hash key and calls Redis TIME exactly once.
rate-fixed-window-v1.lua
rate-sliding-counter-v1.lua
rate-token-bucket-v1.lua
Every program returns exactly seven bounded scalar fields:
status, serverNowMillis, effectiveNowMillis,
limit, remaining, retryAfterMillis, resetAtMillis
Statuses are ALLOWED, DENIED, CLOCK_UNSAFE, STATE_INCOMPATIBLE, INVALID.
Unknown arity/status/numeric syntax/range is a compatibility failure, never allow/fail-open.
Common rules:
- Redis server time drives enforcement;
- small backward movement clamps to stored
lastObservedMillis; - regression beyond policy threshold returns
CLOCK_UNSAFEwithout consuming state; - policy/schema/algorithm mismatch returns
STATE_INCOMPATIBLE; - denied requests do not consume quota;
- state receives a finite TTL;
- all arithmetic stays within
2^53-1; - raw principal/IP/API-key/route never appears in the physical key.
The existing scalar Lua executor stays intact. A structured program path adds bounded MULTI reply
support and uses EVALSHA, falling back to the exact compiled source only on NOSCRIPT.
Algorithm rules
Fixed window stores window ID and consumed count. Allow increments only when
consumed + cost <= limit; retry/reset points to the current window end.
Sliding counter stores previous/current window IDs and counts, using scale 1_000_000 and
conservative ceiling weight. It reports approximate certainty and a bounded conservative retry.
Token bucket stores scaled tokens, last refill time and the sub-token division remainder. Refill is
therefore independent of evaluation frequency, uses quotient/remainder arithmetic without an
unsafe numerator + denominator - 1 intermediate, and saturates at capacity. Denial does not
subtract tokens; retry and full-reset use integer ceiling.
Physical key
The existing canonical builder is reused with:
capability=rate
region=<policyId>
kind=state
digest(policyId, policyRevision, algorithm, subjectDigest)
Policy revision appears in both digest input and stored state. A policy revision therefore rolls to a new key while old state expires naturally.
Runtime and composition
app.rate-limit is disabled by default. Enabling requires:
provider=redis;- one default policy and an exact policy definition;
- a dedicated Redis coordination endpoint and HMAC secret;
- finite command/admission bounds.
Only role=coordination and failure-policy=fail-closed are accepted in v1. Disabled mode creates
no connection, thread or semantic port. Cache Redis settings/beans are never an implicit fallback.
Evidence
Unit tests cover contract bounds, policy arithmetic, key privacy/revision, structured reply
validation, NOSCRIPT, boundary vectors, denial-no-consume, clock regression, pre/post-dispatch
failure certainty and disabled composition. The explicit Redis 7.4 service lane executes all three
programs, exact-boundary admission after a denied non-consuming request, excessive clock-regression
state immutability, TYPE response normalization, token refill-remainder carry, malformed hash-state
classification, cache NX, and observation-token compare-and-replace. Redis 7.4 is the minimum
version declared by the program manifests until a lower-version service lane exists. The caller
deadline is an admission precheck against the fixed command timeout; R1 does not claim per-command
dynamic timeout or hard cancellation after dispatch. Missing TLS/ACL, Sentinel/Cluster, failover and
persistence/eviction evidence keeps the provider at R1.