145 lines
5.9 KiB
Markdown
145 lines
5.9 KiB
Markdown
# 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-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.
|