haechi-ratelimit-redis
v0.1.1
Published
Shared-store (Redis-backed) rateLimiter satellite for Haechi (providers.rateLimiter) — fixed-window counter over an injected store/client.
Maintainers
Readme
haechi-ratelimit-redis
A shared-store (Redis-backed) rateLimiter for Haechi's providers.rateLimiter injection seam. It lives in the Haechi monorepo under satellites/ and is published independently as haechi-ratelimit-redis. Core (haechi) stays zero-runtime-dependency, and so does this satellite by default: the Redis client SDK (redis) is an optional peer dependency, installed only by consumers who use the bundled Redis adapter.
It is the production consumer of the WS3 rate-limiter seam — the Reliability Hardening Track deliberately left "a production shared-store is a future satellite" as a core non-goal, adding only the injection seam to core. This satellite fills it.
Why a shared store
Haechi's built-in rate limiter is per-process, in-memory: behind a load balancer with N replicas, each replica counts independently, so the effective per-identity budget multiplies by N. A shared store (Redis) gives every replica one authoritative counter, so the budget holds across the whole fleet.
How it works
createSharedRateLimiter({ store, windowMs = 60000 }) implements a fixed-window counter. For each (key, current window) it asks the store to atomically increment the counter and apply the window TTL on the first hit, then allows iff the returned count is ≤ limit. The store/client is injected (like the crypto-kms satellite's KMS client), so this package adds no runtime dependency to core.
The proxy awaits rateLimiter.allow(key, limit), so this async limiter gates correctly. It satisfies the same contract as the built-in default — allow(key, limit) -> boolean | Promise<boolean>.
Install
npm install haechi haechi-ratelimit-redis # peer: haechi >=0.8.0 <2.0.0haechi (the core) must be installed — it is a peer dependency, not bundled. The satellite reuses your installed haechi instance (declared as a peer dependency).
The store contract
Inject any store implementing one small async method:
{
// Post-increment counter for the CURRENT fixed window for `key`, with the
// window TTL applied on the first hit (so the window is fixed, not sliding).
async hit(key: string, windowMs: number): Promise<number>
}Usage (Redis)
import { createRuntime } from "haechi/runtime";
import { createClient } from "redis"; // optional peer
import { createSharedRateLimiter } from "haechi-ratelimit-redis";
import { createRedisStore } from "haechi-ratelimit-redis/redis";
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
const rateLimiter = createSharedRateLimiter({
store: createRedisStore({ client }) // keyPrefix defaults to "haechi:rl:"
});
const runtime = createRuntime(config, { rateLimiter });The Redis adapter (haechi-ratelimit-redis/redis)
createRedisStore({ client, keyPrefix = "haechi:rl:" }) adapts an injected node-redis v4/v5 client. hit(key, windowMs) runs a single atomic Lua EVAL:
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return nThe INCR+PEXPIRE are one atomic script, and the TTL is set only on the first hit (n == 1), so the window is fixed — the whole window expires from its start rather than resetting on every request. The key is bucketed by the current window (${keyPrefix}${key}:${bucket}) so a new window always starts a fresh counter.
redis is an optional peer dependency. The client is injected, so this module never imports redis at the top level — install it only if you use the Redis path:
npm install haechi-ratelimit-redis redisFor tests, inject a fake client exposing eval(script, { keys, arguments }) — no SDK or live Redis required.
The memory store (haechi-ratelimit-redis/memory)
createMemoryStore({ now = Date.now } = {}) is a Map-backed implementation of the same hit(key, windowMs) contract. It is a single-process reference / test double:
Not shared. The memory store lives in one process's heap — it is not shared across processes or replicas (each replica gets its own Map, enforcing the budget per-process). It exists to exercise the limiter contract and for tests; never use it as the production shared store. For a real shared store, use the Redis adapter.
now is injectable so tests can drive window rollover deterministically without sleeping.
Self-test
import { createSharedRateLimiter } from "haechi-ratelimit-redis";
import { createMemoryStore } from "haechi-ratelimit-redis/memory";
const limiter = createSharedRateLimiter({ store: createMemoryStore() });
await limiter.allow("alice", 3); // true, true, true, then falseValidating against a real Redis
The unit tests use a mock client. To validate the bundled Redis adapter against a real Redis — and prove that two limiter instances (two replicas) sharing one Redis enforce a single budget — run the optional integration test, which is skipped unless HAECHI_REDIS_URL is set:
npm i -D redis # once, in the repo root (redis is an optional peer)
HAECHI_REDIS_URL=redis://127.0.0.1:6379 \
node --test satellites/ratelimit-redis/ratelimit-redis.integration.test.mjsIt asserts cross-replica shared enforcement (3 hits on replica A + 1 on replica B = the budget; the 5th is denied on either connection), per-identity isolation, and fixed-window reset — all against the live server.
See configuration.md → Rate limiter injection and shared-responsibility.md §4.
