Skip to content

Commit ca56342

Browse files
committed
feat: add rate limiter
Signed-off-by: Raúl Santos <[email protected]>
1 parent d892528 commit ca56342

File tree

10 files changed

+1582
-6
lines changed

10 files changed

+1582
-6
lines changed

frontend/nuxt.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import primevue from './setup/primevue';
88
import echarts from './setup/echarts';
99
import caching from './setup/caching';
1010
import sitemap from './setup/sitemap';
11+
import rateLimiter from './setup/rate-limiter';
1112

1213
const isProduction = process.env.NUXT_APP_ENV === 'production';
1314
const isDevelopment = process.env.NODE_ENV === 'development';
@@ -52,7 +53,7 @@ export default defineNuxtConfig({
5253
primevue,
5354
echarts,
5455
runtimeConfig: {
55-
// These are are only available on the server-side and can be overridden by the .env file
56+
// These are only available on the server-side and can be overridden by the .env file
5657
appEnv: process.env.APP_ENV,
5758
tinybirdBaseUrl: 'https://api.us-west-2.aws.tinybird.co',
5859
tinybirdToken: '',
@@ -79,6 +80,7 @@ export default defineNuxtConfig({
7980
cmDbPassword: 'example',
8081
cmDbDatabase: 'crowd-web',
8182
dataCopilotDefaultSegmentId: '',
83+
rateLimiter: rateLimiter,
8284
// These are also exposed on the client-side
8385
public: {
8486
apiBase: '/api',

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@pinia/nuxt": "^0.11.2",
4040
"@popperjs/core": "^2.11.8",
4141
"@primevue/themes": "^4.4.1",
42+
"@redis/client": "^5.9.0",
4243
"@tanstack/vue-query": "^5.90.5",
4344
"@types/jsonwebtoken": "^9.0.10",
4445
"@vuelidate/core": "^2.0.3",

frontend/pnpm-lock.yaml

Lines changed: 16 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2025 The Linux Foundation and each contributor.
2+
// SPDX-License-Identifier: MIT
3+
4+
import type { H3Event } from 'h3';
5+
import type { RedisClientType } from '@redis/client';
6+
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest';
7+
import type { RateLimiterConfig } from '~~/server/types/rate-limiter';
8+
9+
const checkRateLimitMock = vi.fn();
10+
const mockSetResponseHeaders = vi.fn();
11+
const mockCreateError = vi.fn((error) => error);
12+
13+
// Mock Nuxt/H3 global functions (auto-imported by Nuxt)
14+
global.defineEventHandler = vi.fn((handler) => handler);
15+
global.useRuntimeConfig = vi.fn();
16+
global.setResponseHeaders = mockSetResponseHeaders;
17+
global.createError = mockCreateError;
18+
19+
vi.mock('../utils/rate-limiter', () => ({
20+
checkRateLimit: (...args: unknown[]) => checkRateLimitMock(...args),
21+
}));
22+
23+
let handleRateLimiting: typeof import('./rate-limiter').handleRateLimiting;
24+
25+
beforeAll(async () => {
26+
// Import after mocks are set so the module picks up the mocked dependencies.
27+
({ handleRateLimiting } = await import('./rate-limiter'));
28+
});
29+
30+
const baseConfig: RateLimiterConfig = {
31+
enabled: true,
32+
defaultLimit: { maxRequests: 100, windowSeconds: 60 },
33+
secret: 'secret',
34+
redisDatabase: 0,
35+
rules: [],
36+
exclusions: [],
37+
};
38+
39+
const mockRedisClient = {} as unknown as RedisClientType;
40+
41+
function createEvent(): H3Event {
42+
return {
43+
path: '/api/test',
44+
method: 'GET',
45+
node: {
46+
req: {
47+
socket: {
48+
remoteAddress: '127.0.0.1',
49+
},
50+
},
51+
},
52+
} as unknown as H3Event;
53+
}
54+
55+
describe('handleRateLimiting', () => {
56+
beforeEach(() => {
57+
checkRateLimitMock.mockReset();
58+
mockSetResponseHeaders.mockReset();
59+
mockCreateError.mockReset().mockImplementation((error) => error);
60+
});
61+
62+
afterEach(() => {
63+
vi.clearAllMocks();
64+
});
65+
66+
it('skips rate limiting when disabled', async () => {
67+
const config = { ...baseConfig, enabled: false };
68+
const event = createEvent();
69+
70+
await handleRateLimiting(event, config, mockRedisClient);
71+
72+
expect(checkRateLimitMock).not.toHaveBeenCalled();
73+
expect(mockSetResponseHeaders).not.toHaveBeenCalled();
74+
});
75+
76+
it('sets rate limit headers when request is allowed', async () => {
77+
const config = { ...baseConfig };
78+
const event = createEvent();
79+
checkRateLimitMock.mockResolvedValue({
80+
allowed: true,
81+
limit: 10,
82+
remaining: 9,
83+
resetIn: 30,
84+
current: 1,
85+
});
86+
87+
await handleRateLimiting(event, config, mockRedisClient);
88+
89+
expect(mockSetResponseHeaders).toHaveBeenCalledWith(event, {
90+
'X-RateLimit-Limit': '10',
91+
'X-RateLimit-Remaining': '9',
92+
'X-RateLimit-Reset': '30',
93+
});
94+
});
95+
96+
it('throws a 429 error and sets headers when request is blocked', async () => {
97+
const config = { ...baseConfig };
98+
const event = createEvent();
99+
checkRateLimitMock.mockResolvedValue({
100+
allowed: false,
101+
limit: 5,
102+
remaining: 0,
103+
resetIn: 42,
104+
current: 6,
105+
});
106+
mockCreateError.mockImplementation((error) => error);
107+
108+
await expect(handleRateLimiting(event, config, mockRedisClient)).rejects.toMatchObject({
109+
statusCode: 429,
110+
statusMessage: 'Too Many Requests',
111+
});
112+
113+
expect(mockSetResponseHeaders).toHaveBeenCalledWith(event, {
114+
'X-RateLimit-Limit': '5',
115+
'X-RateLimit-Remaining': '0',
116+
'X-RateLimit-Reset': '42',
117+
});
118+
});
119+
120+
it('rethrows 429 errors originating from checkRateLimit', async () => {
121+
const config = { ...baseConfig };
122+
const event = createEvent();
123+
const rateLimitError = { statusCode: 429, message: 'Too many requests' };
124+
checkRateLimitMock.mockRejectedValue(rateLimitError);
125+
126+
await expect(handleRateLimiting(event, config, mockRedisClient)).rejects.toBe(rateLimitError);
127+
});
128+
129+
it('logs and continues when a non-429 error occurs', async () => {
130+
const config = { ...baseConfig };
131+
const event = createEvent();
132+
const unexpectedError = new Error('redis unavailable');
133+
checkRateLimitMock.mockRejectedValue(unexpectedError);
134+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
135+
136+
await handleRateLimiting(event, config, mockRedisClient);
137+
138+
expect(consoleSpy).toHaveBeenCalledWith('Rate limiter error:', unexpectedError);
139+
expect(mockSetResponseHeaders).not.toHaveBeenCalled();
140+
});
141+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) 2025 The Linux Foundation and each contributor.
2+
// SPDX-License-Identifier: MIT
3+
4+
import type { H3Event } from 'h3';
5+
import { RedisClientType } from '@redis/client';
6+
import { checkRateLimit } from '../utils/rate-limiter';
7+
import { getRedisClient } from '../utils/redis-client';
8+
import { RateLimiterConfig } from '~~/server/types/rate-limiter';
9+
10+
/**
11+
* This is a rate-limiting middleware that checks incoming requests against the configured rate
12+
* limits and blocks requests that exceed the limits. The defineEventHandler entrypoint is simple
13+
* and delegates the actual logic to the handleRateLimiting function for easier testing, allowing
14+
* injection of mocked dependencies and configuration.
15+
*
16+
* Features:
17+
* - Uses Redis for distributed rate limiting
18+
* - Hashes IP addresses for GDPR compliance
19+
* - Supports per-route and per-method limits
20+
* - Adds rate limit headers to responses
21+
*/
22+
export default defineEventHandler(async (event: H3Event) => {
23+
const config = useRuntimeConfig();
24+
const rateLimiterConfig = config.rateLimiter as RateLimiterConfig;
25+
26+
// getRedisClient memoizes the client instance, so it's not a problem to call it multiple times.
27+
const redisClient = await getRedisClient(config.redisUrl, rateLimiterConfig.redisDatabase, true);
28+
29+
await handleRateLimiting(event, rateLimiterConfig, redisClient);
30+
});
31+
32+
/**
33+
* Handles rate limiting for the given event and rate limiter configuration.
34+
*
35+
* @param event - The H3 event object for the incoming request.
36+
* @param rateLimiterConfig - The rate limiter configuration to use.
37+
* @param redisClient - The Redis client instance to use for rate limiting.
38+
*/
39+
export async function handleRateLimiting(
40+
event: H3Event,
41+
rateLimiterConfig: RateLimiterConfig,
42+
redisClient: RedisClientType,
43+
) {
44+
// Skip rate limiting if disabled
45+
if (!rateLimiterConfig.enabled) {
46+
return;
47+
}
48+
49+
try {
50+
const result = await checkRateLimit(event, rateLimiterConfig, redisClient);
51+
52+
setResponseHeaders(event, {
53+
['X-RateLimit-Limit']: result.limit.toString(),
54+
['X-RateLimit-Remaining']: result.remaining.toString(),
55+
['X-RateLimit-Reset']: result.resetIn.toString(),
56+
});
57+
58+
// Block request if rate limit exceeded
59+
if (!result.allowed) {
60+
throw createError({
61+
statusCode: 429,
62+
statusMessage: 'Too Many Requests',
63+
message: `Rate limit exceeded. Please wait ${result.resetIn} seconds before trying again.`,
64+
});
65+
}
66+
} catch (error) {
67+
// If it's already a 429 error, re-throw it
68+
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 429) {
69+
throw error;
70+
}
71+
72+
// Log other errors but don't block the request. This way the app keeps working even if Redis
73+
// is down or the rate limiter fails for some other reason.
74+
console.error('Rate limiter error:', error);
75+
}
76+
}

0 commit comments

Comments
 (0)