diff options
Diffstat (limited to 'packages/plugins/ratelimit-memory/src')
| -rw-r--r-- | packages/plugins/ratelimit-memory/src/index.test.ts | 253 | ||||
| -rw-r--r-- | packages/plugins/ratelimit-memory/src/index.ts | 86 |
2 files changed, 339 insertions, 0 deletions
diff --git a/packages/plugins/ratelimit-memory/src/index.test.ts b/packages/plugins/ratelimit-memory/src/index.test.ts new file mode 100644 index 00000000..5bbed769 --- /dev/null +++ b/packages/plugins/ratelimit-memory/src/index.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RateLimiter } from "./index"; + +describe("RateLimiter", () => { + let rateLimiter: RateLimiter; + + beforeEach(() => { + rateLimiter = new RateLimiter(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + rateLimiter.clear(); + }); + + describe("checkRateLimit", () => { + it("should allow requests within rate limit", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 3, + }; + + const result1 = rateLimiter.checkRateLimit(config, "user1"); + const result2 = rateLimiter.checkRateLimit(config, "user1"); + const result3 = rateLimiter.checkRateLimit(config, "user1"); + + expect(result1.allowed).toBe(true); + expect(result2.allowed).toBe(true); + expect(result3.allowed).toBe(true); + }); + + it("should block requests exceeding rate limit", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 2, + }; + + const result1 = rateLimiter.checkRateLimit(config, "user1"); + const result2 = rateLimiter.checkRateLimit(config, "user1"); + const result3 = rateLimiter.checkRateLimit(config, "user1"); + + expect(result1.allowed).toBe(true); + expect(result2.allowed).toBe(true); + expect(result3.allowed).toBe(false); + expect(result3.resetInSeconds).toBeDefined(); + expect(result3.resetInSeconds).toBeGreaterThan(0); + }); + + it("should reset after window expires", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 2, + }; + + // First two requests allowed + const result1 = rateLimiter.checkRateLimit(config, "user1"); + const result2 = rateLimiter.checkRateLimit(config, "user1"); + expect(result1.allowed).toBe(true); + expect(result2.allowed).toBe(true); + + // Third request blocked + const result3 = rateLimiter.checkRateLimit(config, "user1"); + expect(result3.allowed).toBe(false); + + // Advance time past the window + vi.advanceTimersByTime(61000); + + // Should allow request after window reset + const result4 = rateLimiter.checkRateLimit(config, "user1"); + expect(result4.allowed).toBe(true); + }); + + it("should isolate rate limits by identifier", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 1, + }; + + const result1 = rateLimiter.checkRateLimit(config, "user1"); + const result2 = rateLimiter.checkRateLimit(config, "user2"); + + expect(result1.allowed).toBe(true); + expect(result2.allowed).toBe(true); + }); + + it("should isolate rate limits by key", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 1, + }; + + const result1 = rateLimiter.checkRateLimit(config, "user1:/api/v1"); + const result2 = rateLimiter.checkRateLimit(config, "user1:/api/v2"); + + expect(result1.allowed).toBe(true); + expect(result2.allowed).toBe(true); + }); + + it("should isolate rate limits by config name", () => { + const config1 = { + name: "api", + windowMs: 60000, + maxRequests: 1, + }; + const config2 = { + name: "auth", + windowMs: 60000, + maxRequests: 1, + }; + + const result1 = rateLimiter.checkRateLimit(config1, "user1"); + const result2 = rateLimiter.checkRateLimit(config2, "user1"); + + expect(result1.allowed).toBe(true); + expect(result2.allowed).toBe(true); + }); + + it("should calculate correct resetInSeconds", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 1, + }; + + // First request allowed + rateLimiter.checkRateLimit(config, "user1"); + + // Advance time by 30 seconds + vi.advanceTimersByTime(30000); + + // Second request blocked + const result = rateLimiter.checkRateLimit(config, "user1"); + expect(result.allowed).toBe(false); + // Should have ~30 seconds remaining + expect(result.resetInSeconds).toBeGreaterThan(29); + expect(result.resetInSeconds).toBeLessThanOrEqual(30); + }); + }); + + describe("reset", () => { + it("should reset rate limit for specific identifier", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 1, + }; + + // Use up the limit + rateLimiter.checkRateLimit(config, "user1"); + const result1 = rateLimiter.checkRateLimit(config, "user1"); + expect(result1.allowed).toBe(false); + + // Reset the limit + rateLimiter.reset(config, "user1"); + + // Should allow request again + const result2 = rateLimiter.checkRateLimit(config, "user1"); + expect(result2.allowed).toBe(true); + }); + + it("should reset rate limit for specific key", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 1, + }; + + // Use up the limit for key1 + rateLimiter.checkRateLimit(config, "user1:/path1"); + const result1 = rateLimiter.checkRateLimit(config, "user1:/path1"); + expect(result1.allowed).toBe(false); + + // Reset only key1 + rateLimiter.reset(config, "user1:/path1"); + + // key1 should be allowed + const result2 = rateLimiter.checkRateLimit(config, "user1:/path1"); + expect(result2.allowed).toBe(true); + }); + + it("should not affect other identifiers", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 1, + }; + + // Use up limits for both users + rateLimiter.checkRateLimit(config, "user1"); + rateLimiter.checkRateLimit(config, "user2"); + + // Reset only user1 + rateLimiter.reset(config, "user1"); + + const result1 = rateLimiter.checkRateLimit(config, "user1"); + const result2 = rateLimiter.checkRateLimit(config, "user2"); + + expect(result1.allowed).toBe(true); + expect(result2.allowed).toBe(false); + }); + }); + + describe("clear", () => { + it("should clear all rate limits", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 1, + }; + + // Use up limits for multiple users + rateLimiter.checkRateLimit(config, "user1"); + rateLimiter.checkRateLimit(config, "user2"); + + // Clear all limits + rateLimiter.clear(); + + // All should be allowed + const result1 = rateLimiter.checkRateLimit(config, "user1"); + const result2 = rateLimiter.checkRateLimit(config, "user2"); + + expect(result1.allowed).toBe(true); + expect(result2.allowed).toBe(true); + }); + }); + + describe("cleanup", () => { + it("should cleanup expired entries", () => { + const config = { + name: "test", + windowMs: 60000, + maxRequests: 1, + }; + + // Create an entry + rateLimiter.checkRateLimit(config, "user1"); + + // Advance time past window + cleanup interval + vi.advanceTimersByTime(61000 + 60000); + + // Entry should be cleaned up and new request allowed + const result = rateLimiter.checkRateLimit(config, "user1"); + expect(result.allowed).toBe(true); + }); + }); +}); diff --git a/packages/plugins/ratelimit-memory/src/index.ts b/packages/plugins/ratelimit-memory/src/index.ts new file mode 100644 index 00000000..c47889d3 --- /dev/null +++ b/packages/plugins/ratelimit-memory/src/index.ts @@ -0,0 +1,86 @@ +import type { + RateLimitClient, + RateLimitConfig, + RateLimitResult, +} from "@karakeep/shared/ratelimiting"; +import { PluginProvider } from "@karakeep/shared/plugins"; + +interface RateLimitEntry { + count: number; + resetTime: number; +} + +export class RateLimiter implements RateLimitClient { + private store = new Map<string, RateLimitEntry>(); + private cleanupProbability: number; + + constructor(cleanupProbability = 0.01) { + // Probability of cleanup on each check (default 1%) + this.cleanupProbability = cleanupProbability; + } + + private cleanupExpiredEntries() { + const now = Date.now(); + for (const [key, entry] of this.store.entries()) { + if (now > entry.resetTime) { + this.store.delete(key); + } + } + } + + checkRateLimit(config: RateLimitConfig, key: string): RateLimitResult { + if (!key) { + return { allowed: true }; + } + + // Probabilistic cleanup + if (Math.random() < this.cleanupProbability) { + this.cleanupExpiredEntries(); + } + + const rateLimitKey = `${config.name}:${key}`; + const now = Date.now(); + + let entry = this.store.get(rateLimitKey); + + if (!entry || now > entry.resetTime) { + entry = { + count: 1, + resetTime: now + config.windowMs, + }; + this.store.set(rateLimitKey, entry); + return { allowed: true }; + } + + if (entry.count >= config.maxRequests) { + const resetInSeconds = Math.ceil((entry.resetTime - now) / 1000); + return { + allowed: false, + resetInSeconds, + }; + } + + entry.count++; + return { allowed: true }; + } + + reset(config: RateLimitConfig, key: string) { + const rateLimitKey = `${config.name}:${key}`; + this.store.delete(rateLimitKey); + } + + clear() { + this.store.clear(); + } +} + +export class RateLimitProvider implements PluginProvider<RateLimitClient> { + private client: RateLimiter | null = null; + + async getClient(): Promise<RateLimitClient | null> { + if (!this.client) { + this.client = new RateLimiter(); + } + return this.client; + } +} |
