Files
tech-log-backend/docs/superpowers/specs/2026-07-28-redis-distributed-rate-limit-design.md

145 lines
5.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```text
rate-fixed-window-v1.lua
rate-sliding-counter-v1.lua
rate-token-bucket-v1.lua
```
Every program returns exactly seven bounded scalar fields:
```text
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:
```text
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.