Files
clean-architecture-backend-…/docs/superpowers/specs/2026-07-28-redis-distributed-rate-limit-design.md
T

5.9 KiB
Raw Blame History

Redis Distributed Rate-Limit Increment Design

Status: implemented as standalone R1

Parent: 2026-07-26-redis-production-capability-design.md §§1921

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-contract owns 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-redis owns Redis keys, atomic Lua programs, structured reply parsing, failure certainty and the provider implementation.
  • app-bootstrap owns 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_UNSAFE without 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.