diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..f06d01e --- /dev/null +++ b/src/middleware/rateLimiter.ts @@ -0,0 +1,46 @@ +import { Request, Response, NextFunction } from 'express'; +import redis from '../config/redis.js'; + +// Configuration: Allow 100 requests per 60 seconds per client +const WINDOW_SIZE_IN_SECONDS = 60; +const MAX_REQUESTS_ALLOWED = 100; + +export const rateLimiter = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + // Identify client by authenticated client ID, falling back to IP address + const clientId = req.client?.id || req.ip || 'anonymous'; + const redisKey = `rate_limit:${clientId}`; + + try { + // TODO: [Exercise - Redis Sliding Window Rate Limiter] + // Implement a sliding-window rate limiter using Redis sorted sets (zset). + // + // Algorithm Steps: + // 1. Get the current timestamp in milliseconds (e.g., const now = Date.now()). + // 2. Define the window boundary (e.g., const clearBefore = now - WINDOW_SIZE_IN_SECONDS * 1000). + // 3. Execute a Redis transaction (pipeline) to perform these operations atomically: + // a. ZREMRANGEBYSCORE: Remove elements (timestamps) older than 'clearBefore'. + // b. ZADD: Add the current timestamp 'now' with a unique member value (e.g. timestamp or random string) to the sorted set. + // c. ZCARD: Get the count of active members in the sorted set. + // d. EXPIRE: Update the key's TTL to at least WINDOW_SIZE_IN_SECONDS to clean up idle keys. + // 4. Retrieve the count from the transaction results. + // 5. If the count exceeds MAX_REQUESTS_ALLOWED: + // - Set HTTP headers: 'Retry-After' (in seconds) and 'X-RateLimit-Limit' / 'X-RateLimit-Remaining'. + // - Return an HTTP 429 Too Many Requests response with a JSON error body. + // 6. If the count is within limits, call next() to allow the request. + // + // Resources: + // - ioredis transactions: redis.multi().zremrangebyscore(...).zadd(...).zcard(...).pexpire(...).exec() + + // Bypassing rate limiting for now (skeleton fallback) + next(); + } catch (error) { + console.error('❌ Rate Limiter Error:', error); + // In production, we fail-open (call next()) to prevent a Redis crash from taking down the API, + // or fail-closed based on risk posture. Let's fail-open for now. + next(); + } +}; diff --git a/src/routes/event.ts b/src/routes/event.ts index 0d36d48..287ecbc 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -3,6 +3,7 @@ import { validate } from '../middleware/validate.js'; import { CreateEventSchema } from '../schemas/event.js'; import { authenticate } from '../middleware/auth.js'; import { enforceIdempotency } from '../middleware/idempotency.js'; +import { rateLimiter } from '../middleware/rateLimiter.js'; import redis from '../config/redis.js'; import { producer } from '../config/kafka.js'; @@ -13,6 +14,7 @@ const KAFKA_TOPIC = 'metrics.raw'; router.post( '/events', authenticate, + rateLimiter, enforceIdempotency, validate(CreateEventSchema), async (req: Request, res: Response, next: NextFunction): Promise => {