-
Notifications
You must be signed in to change notification settings - Fork 1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: use redis for rate limiting & next caching to resolve memory issues #2078
feat: use redis for rate limiting & next caching to resolve memory issues #2078
Conversation
The latest updates on your projects. Learn more about Vercel for Git ↗︎ 2 Ignored Deployments
|
Thank you for following the naming conventions for pull request titles! 🙏 |
apps/web/app/middleware/rateLimit.tsInstead of using fetch for Redis operations in the selfHostingRateLimiter function, consider using a Redis client library like ioredis. This would provide better performance and error handling capabilities. import Redis from 'ioredis';
const redis = new Redis(REDIS_HTTP_CLIENT_URL);
const selfHostingRateLimiter = async (options: Options) => {
const tokenCount = await redis.incr(token);
if (tokenCount === 1) {
await redis.expire(token, options.interval / 1000);
}
if (tokenCount > options.allowedPerInterval) {
throw new Error("Rate limit exceeded for IP: " + token);
}
};
docker-compose.ymlConsider adding a network configuration to the webdis service to restrict its access to only the necessary services. This would enhance the security of the application by limiting the potential attack surface. webdis:
image: nicolas/webdis
ports:
- 7379:7379
restart: unless-stopped
networks:
- formbricks
|
apps/web/app/middleware/rateLimit.ts
Outdated
const selfHostingRateLimiter = (options: Options) => { | ||
return async (token: string) => { | ||
const tokenCountResponse = await fetch(`${REDIS_HTTP_CLIENT_URL}/INCR/${token}`); | ||
const tokenCountData = await tokenCountResponse.json(); | ||
const tokenCount = parseInt(tokenCountData["INCR"]); | ||
if (tokenCount === 1) { | ||
await fetch(`${REDIS_HTTP_CLIENT_URL}/EXPIRE/${token}/${options.interval / 1000}`); | ||
} | ||
|
||
const currentUsage = tokenCount[0]; | ||
const isRateLimited = currentUsage >= options.allowedPerInterval; | ||
return isRateLimited ? reject() : resolve(); | ||
}), | ||
if (tokenCount > options.allowedPerInterval) { | ||
throw new Error("Rate limit exceeded for IP: " + token); | ||
} | ||
}; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fetch API has been replaced with the ioredis library for Redis operations in the selfHostingRateLimiter function. This change improves performance and error handling.
const selfHostingRateLimiter = (options: Options) => { | |
return async (token: string) => { | |
const tokenCountResponse = await fetch(`${REDIS_HTTP_CLIENT_URL}/INCR/${token}`); | |
const tokenCountData = await tokenCountResponse.json(); | |
const tokenCount = parseInt(tokenCountData["INCR"]); | |
if (tokenCount === 1) { | |
await fetch(`${REDIS_HTTP_CLIENT_URL}/EXPIRE/${token}/${options.interval / 1000}`); | |
} | |
const currentUsage = tokenCount[0]; | |
const isRateLimited = currentUsage >= options.allowedPerInterval; | |
return isRateLimited ? reject() : resolve(); | |
}), | |
if (tokenCount > options.allowedPerInterval) { | |
throw new Error("Rate limit exceeded for IP: " + token); | |
} | |
}; | |
}; | |
import Redis from 'ioredis'; | |
const redis = new Redis(REDIS_HTTP_CLIENT_URL); | |
const selfHostingRateLimiter = async (options: Options) => { | |
const tokenCount = await redis.incr(token); | |
if (tokenCount === 1) { | |
await redis.expire(token, options.interval / 1000); | |
} | |
if (tokenCount > options.allowedPerInterval) { | |
throw new Error("Rate limit exceeded for IP: " + token); | |
} | |
}; |
docker-compose.yml
Outdated
webdis: | ||
image: nicolas/webdis | ||
ports: | ||
- 7379:7379 | ||
restart: unless-stopped |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding a network configuration to the webdis service to restrict its access to only the necessary services. This enhances the security of the application by limiting the potential attack surface.
webdis: | |
image: nicolas/webdis | |
ports: | |
- 7379:7379 | |
restart: unless-stopped | |
webdis: | |
image: nicolas/webdis | |
ports: | |
- 7379:7379 | |
restart: unless-stopped | |
networks: | |
- formbricks |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ShubhamPalriwala thanks for the PR and the investigation 💪🚀 A few comments as we are moving to a new infrastructure where we might also use this in Formbricks Cloud:
apps/web/app/middleware/rateLimit.ts
Outdated
}; | ||
|
||
export default function rateLimit(options: Options) { | ||
if (IS_FORMBRICKS_CLOUD || !REDIS_HTTP_CLIENT_URL) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need the IS_FORMBRICKS_CLOUD
check here or could be just check if redis is configured?
apps/web/app/middleware/rateLimit.ts
Outdated
}; | ||
}; | ||
|
||
const selfHostingRateLimiter = (options: Options) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would rename them as we shouldn't distinguish between cloud and self-hosting but if redis is configured or not.
…shubham/for-1862-reproduce-flix-scalability-issues
…to not be restarted
…shubham/for-1862-reproduce-flix-scalability-issues
What does this PR do?
REDIS_CLIENT_URL
that users can use to setHow should this be tested?
Check Memory Usage on running both when
It should improve significantly & the GC should also periodically bring it down in the load drops!
Fixes the Memory Leak based on the below findings
Capture_2024-02-09-13-23-51.mp4
And on dropping the load to 0 reqs/sec, the Node Garbage Collector (GC) cleaned things up but could NOT bring it under 600 MB.
We then commented the codebase of a couple of endpoints to understand where the memory leak is actually happening ie infra/code basically narrowing down the scope and our finding was:
This also did not feel like our solution!
Capture_2024-02-12-14-05-13.mp4
Our
middleware.js
had a ton of closures & array calls!So now going deeper into the rabbit hole, the next thing to find was why was this happening ie if these are per-requests, the GC should clean them up at a decent rate post response. So we now used the
--trace-gc
for node to see how the GC is performing. Turned out the GC was working fine.Now we get back to debugging the heap snapshots and then found out that as the number of different IP users increased, the heap size increased, hinting at our internal
LRU Cache
that we used!Next debug step was to comment out the
middleware.js
logic and see how the memory leak was performing and boooom, the memory handled the load pretty well without going crazy! We found our CULPRIT!Now, after a lot of ideation and discussions internally, we implemented the
webdis
(Redis HTTP client + server) because the Nextjs middleware is compiled to run on edge runtime (serverless, cloudflare workers)(it cannot connect with Redis using its own protocol or on top of raw TCP) so now on self hosting instances, we recommend to use Redis for rate limiting buckets!All the end users needs to do is:
Run the below service in their existing docker-compose.yml:
And pass this env var to Formbricks:
REDIS_HTTP_CLIENT_URL: http://webdis:7379
Checklist
Required
pnpm build
console.logs
git pull origin main
Appreciated