// lib/rateLimiter.ts
import Redis from 'ioredis';
const redis = new Redis(
process.env.REDIS_URL!);
interface RateLimitResult {
allowed: boolean;
limit: number;
remaining: number;
resetAt: number; // epoch seconds
retryAfter?: number; // seconds
}
async function slidingWindowCheck(
key: string,
limit: number,
windowSec: number,
): Promise<RateLimitResult> {
const now = Date.now();
const windowMs = windowSec * 1000;
const currentWindow =
Math.floor(now / windowMs);
const previousWindow =
currentWindow - 1;
// Keys for current and previous
const currKey =
`rl:${key}:${currentWindow}`;
const prevKey =
`rl:${key}:${previousWindow}`;
// Atomic: increment current,
// read previous
const pipe = redis.pipeline();
pipe.incr(currKey);
pipe.expire(currKey, windowSec * 2);
pipe.get(prevKey);
const results = await pipe.exec();
const currCount =
(results![0][1] as number) || 0;
const prevCount =
parseInt(results![2][1] as string
|| '0');
// Sliding window weight:
// how far into current window?
const elapsed =
now - currentWindow * windowMs;
const weight =
1 - elapsed / windowMs;
// Weighted count
const count =
Math.floor(prevCount * weight)
+ currCount;
const resetAt = Math.ceil(
(currentWindow + 1) * windowMs
/ 1000);
if (count > limit) {
return {
allowed: false,
limit,
remaining: 0,
resetAt,
retryAfter:
resetAt
- Math.floor(now / 1000),
};
}
return {
allowed: true,
limit,
remaining:
Math.max(0, limit - count),
resetAt,
};
}
// config/rateLimits.ts
const RATE_LIMITS = {
free: {
requestsPerMinute: 100,
uploadsPerHour: 20,
aiEnhancePerDay: 5,
},
premium: {
requestsPerMinute: 1000,
uploadsPerHour: 200,
aiEnhancePerDay: 100,
},
enterprise: {
requestsPerMinute: 10000,
uploadsPerHour: 2000,
aiEnhancePerDay: -1, // unlimited
},
} as const;
function getLimits(user?: User) {
if (!user) return RATE_LIMITS.free;
return RATE_LIMITS[user.plan]
?? RATE_LIMITS.free;
}
// middleware/rateLimit.ts
function rateLimitMiddleware(
getLimitKey: (req: Request) =>
{ key: string; limit: number;
window: number }
) {
return async (
req: Request,
res: Response,
next: NextFunction
) => {
const { key, limit, window } =
getLimitKey(req);
const result =
await slidingWindowCheck(
key, limit, window);
// Always set headers
res.set({
'X-RateLimit-Limit':
String(result.limit),
'X-RateLimit-Remaining':
String(result.remaining),
'X-RateLimit-Reset':
String(result.resetAt),
});
if (!result.allowed) {
res.set('Retry-After',
String(result.retryAfter));
return res.status(429).json({
error: 'Too Many Requests',
retryAfter: result.retryAfter,
upgradeUrl:
'/pricing',
});
}
next();
};
}
// Apply to API routes:
app.use('/api',
rateLimitMiddleware((req) => {
const user = req.user;
const limits = getLimits(user);
return {
key: user?.id ?? req.ip,
limit: limits.requestsPerMinute,
window: 60,
};
})
);
// Expensive endpoints get stricter
// limits
app.post('/api/images/upload',
rateLimitMiddleware((req) => ({
key: `upload:${req.user?.id
?? req.ip}`,
limit: getLimits(req.user)
.uploadsPerHour,
window: 3600, // 1 hour
})),
uploadHandler
);
app.post('/api/images/ai-enhance',
rateLimitMiddleware((req) => ({
key: `ai:${req.user?.id
?? req.ip}`,
limit: getLimits(req.user)
.aiEnhancePerDay,
window: 86400, // 24 hours
})),
aiEnhanceHandler
);
// Login: strict per-IP to prevent
// brute force
app.post('/api/auth/login',
rateLimitMiddleware(() => ({
key: `login:${req.ip}`,
limit: 10,
window: 900, // 15 minutes
})),
loginHandler
);
// If Redis is down, DON'T block
// all requests — fail open
async function slidingWindowCheck(
key: string,
limit: number,
windowSec: number,
): Promise<RateLimitResult> {
try {
// ... normal Redis logic ...
} catch (error) {
logger.error(
'Rate limiter Redis error',
{ error });
// Fail open: allow the request
// Better to serve some abusers
// than to block all users
return {
allowed: true,
limit,
remaining: limit,
resetAt: Math.floor(
Date.now() / 1000) + windowSec,
};
}
}
// Also: in-memory fallback
// If Redis is down for > 1 minute,
// switch to a Map-based limiter
// (per-instance, not distributed,
// but better than nothing).
git switch -c security/PIXELCRAFT-102-rate-limiting
git add src/lib/rateLimiter.ts src/middleware/ src/config/
git commit -m "Add sliding window rate limiting with Redis (PIXELCRAFT-102)"
git push origin security/PIXELCRAFT-102-rate-limiting
# PR → Review → Merge → Close ticket ✅
Four rate limiting algorithms, each with different tradeoffs.
The choice depends on whether you prioritize simplicity, fairness, burst tolerance, or smooth traffic shaping.