Skip to content

Commit 06d1812

Browse files
authored
Merge pull request #83 from sidclawhq/feat/redis-rate-limit
feat(api): Redis-backed rate limiting behind REDIS_URL
2 parents e1be368 + 03f9c96 commit 06d1812

5 files changed

Lines changed: 240 additions & 2 deletions

File tree

apps/api/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ DASHBOARD_URL=http://localhost:3000
1010

1111
# Rate limiting (set to false for development)
1212
RATE_LIMIT_ENABLED=true
13+
# Optional: shared rate-limit state for multi-instance deployments.
14+
# Unset = per-process in-memory limiter (fine for a single instance).
15+
# REDIS_URL=redis://localhost:6379
1316

1417
# GitHub OAuth (optional — signup via GitHub disabled if not set)
1518
GITHUB_CLIENT_ID=

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"dotenv": "^16.6.1",
3131
"fastify": "^5",
3232
"fastify-plugin": "^5.1.0",
33+
"ioredis": "^5.11.1",
3334
"openid-client": "^6.8.4",
3435
"pg": "^8.22.0",
3536
"pino": "^10.3.1",

apps/api/src/middleware/rate-limit.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import fp from 'fastify-plugin';
22
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
3+
// Importing ioredis does not open a connection — a client is only
4+
// constructed when REDIS_URL is configured (see createRateLimiter).
5+
import Redis from 'ioredis';
36

47
// ─── Interfaces ──────────────────────────────────────────────────────────────
58

@@ -12,6 +15,8 @@ export interface RateLimitResult {
1215

1316
export interface RateLimiter {
1417
check(key: string, limit: number, windowSeconds: number): Promise<RateLimitResult>;
18+
/** Clear all window state. Used by tests. */
19+
reset(): void | Promise<void>;
1520
}
1621

1722
// ─── In-Memory Implementation ────────────────────────────────────────────────
@@ -57,6 +62,72 @@ export class InMemoryRateLimiter implements RateLimiter {
5762
}
5863
}
5964

65+
// ─── Redis Implementation ────────────────────────────────────────────────────
66+
67+
interface RedisMultiChain {
68+
incr(key: string): RedisMultiChain;
69+
expire(key: string, seconds: number, nx: 'NX'): RedisMultiChain;
70+
exec(): Promise<unknown>;
71+
}
72+
73+
export interface RedisLike {
74+
multi(): RedisMultiChain;
75+
ttl(key: string): Promise<number>;
76+
}
77+
78+
/**
79+
* Redis-backed fixed-window rate limiter for multi-instance deployments.
80+
* Enabled by setting REDIS_URL. Uses INCR + EXPIRE NX so all instances share
81+
* one window per key.
82+
*
83+
* Degrades rather than breaks: if Redis is unreachable, falls back to the
84+
* per-process in-memory limiter (weaker isolation, but requests keep flowing
85+
* and abuse is still bounded per instance) and logs the failure at most once
86+
* per minute.
87+
*/
88+
export class RedisRateLimiter implements RateLimiter {
89+
private readonly fallback = new InMemoryRateLimiter();
90+
private lastErrorLogAt = 0;
91+
92+
// Structural type covering the slice of ioredis the limiter uses; kept
93+
// loose so tests can supply an in-process fake without a Redis server.
94+
constructor(private readonly redis: RedisLike) {}
95+
96+
async check(key: string, limit: number, windowSeconds: number): Promise<RateLimitResult> {
97+
const now = Math.floor(Date.now() / 1000);
98+
const redisKey = `ratelimit:${key}:${Math.floor(now / windowSeconds)}`;
99+
try {
100+
const results = (await this.redis
101+
.multi()
102+
.incr(redisKey)
103+
// NX: only set the TTL when the key has none — first request in the
104+
// window owns the expiry; later requests must not extend it.
105+
.expire(redisKey, windowSeconds, 'NX')
106+
.exec()) as Array<[Error | null, unknown]> | null;
107+
if (!results) throw new Error('redis MULTI aborted');
108+
const [incrErr, count] = results[0]!;
109+
if (incrErr) throw incrErr;
110+
const used = Number(count);
111+
return {
112+
allowed: used <= limit,
113+
limit,
114+
remaining: Math.max(0, limit - used),
115+
resetAt: (Math.floor(now / windowSeconds) + 1) * windowSeconds,
116+
};
117+
} catch (error) {
118+
if (now - this.lastErrorLogAt >= 60) {
119+
this.lastErrorLogAt = now;
120+
console.error('Rate limiter: Redis unavailable, using in-memory fallback:', error instanceof Error ? error.message : error);
121+
}
122+
return this.fallback.check(key, limit, windowSeconds);
123+
}
124+
}
125+
126+
reset(): void {
127+
this.fallback.reset();
128+
}
129+
}
130+
60131
// ─── Rate Limit Tiers ────────────────────────────────────────────────────────
61132

62133
export interface RateLimitTier {
@@ -80,7 +151,22 @@ export function getEndpointCategory(method: string, url: string): 'evaluate' | '
80151

81152
// ─── Fastify Plugin ──────────────────────────────────────────────────────────
82153

83-
export const rateLimiter = new InMemoryRateLimiter();
154+
function createRateLimiter(): RateLimiter {
155+
const redisUrl = process.env['REDIS_URL'];
156+
if (!redisUrl) return new InMemoryRateLimiter();
157+
const client = new Redis(redisUrl, {
158+
// Never let a slow Redis stall requests: short timeouts, no retry queue.
159+
connectTimeout: 2000,
160+
commandTimeout: 1000,
161+
maxRetriesPerRequest: 1,
162+
enableOfflineQueue: false,
163+
lazyConnect: false,
164+
});
165+
console.log('Rate limiter: using Redis backend');
166+
return new RedisRateLimiter(client);
167+
}
168+
169+
export const rateLimiter = createRateLimiter();
84170

85171
async function rateLimitPluginImpl(app: FastifyInstance) {
86172
app.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { RedisRateLimiter } from './rate-limit.js';
3+
4+
/** Minimal in-process fake of the Redis commands the limiter uses. */
5+
function fakeRedis(initial: Record<string, number> = {}) {
6+
const store = new Map<string, number>(Object.entries(initial));
7+
return {
8+
store,
9+
multi() {
10+
const ops: Array<() => [null, unknown]> = [];
11+
const chain = {
12+
incr: (key: string) => {
13+
ops.push(() => {
14+
const next = (store.get(key) ?? 0) + 1;
15+
store.set(key, next);
16+
return [null, next];
17+
});
18+
return chain;
19+
},
20+
expire: (_key: string, _s: number, _nx: 'NX') => {
21+
ops.push(() => [null, 1]);
22+
return chain;
23+
},
24+
exec: async () => ops.map((op) => op()),
25+
};
26+
return chain;
27+
},
28+
ttl: async () => 60,
29+
};
30+
}
31+
32+
describe('RedisRateLimiter', () => {
33+
it('counts across calls and enforces the limit', async () => {
34+
const limiter = new RedisRateLimiter(fakeRedis());
35+
const first = await limiter.check('tenant:read', 2, 60);
36+
expect(first).toMatchObject({ allowed: true, remaining: 1 });
37+
await limiter.check('tenant:read', 2, 60);
38+
const third = await limiter.check('tenant:read', 2, 60);
39+
expect(third.allowed).toBe(false);
40+
expect(third.remaining).toBe(0);
41+
});
42+
43+
it('keys are shared state — a second limiter over the same store sees the count', async () => {
44+
const redis = fakeRedis();
45+
const a = new RedisRateLimiter(redis);
46+
const b = new RedisRateLimiter(redis);
47+
await a.check('k', 2, 60);
48+
await b.check('k', 2, 60);
49+
const result = await b.check('k', 2, 60);
50+
expect(result.allowed).toBe(false);
51+
});
52+
53+
it('falls back to in-memory when Redis errors, and requests keep flowing', async () => {
54+
const broken = {
55+
multi() {
56+
return {
57+
incr: () => broken.multi(),
58+
expire: () => broken.multi(),
59+
exec: async () => {
60+
throw new Error('ECONNREFUSED');
61+
},
62+
// satisfy the structural chain type
63+
} as never;
64+
},
65+
ttl: async () => -1,
66+
};
67+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
68+
const limiter = new RedisRateLimiter(broken as never);
69+
const first = await limiter.check('k', 1, 60);
70+
expect(first.allowed).toBe(true);
71+
const second = await limiter.check('k', 1, 60);
72+
expect(second.allowed).toBe(false); // in-memory fallback still enforces
73+
expect(spy).toHaveBeenCalledTimes(1); // error logged once, not per request
74+
spy.mockRestore();
75+
});
76+
77+
it('reset clears the fallback state', async () => {
78+
const limiter = new RedisRateLimiter(fakeRedis());
79+
limiter.reset();
80+
const result = await limiter.check('k', 5, 60);
81+
expect(result.allowed).toBe(true);
82+
});
83+
});

package-lock.json

Lines changed: 66 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)