stock-bot/apps/wcag-ada/api/src/middleware/rate-limiter.ts
2025-06-28 11:11:34 -04:00

75 lines
No EOL
2.2 KiB
TypeScript

import { Context, Next } from 'hono';
import Redis from 'ioredis';
import { config } from '../config';
import { logger } from '../utils/logger';
import { getWorkerConfig } from '@wcag-ada/config';
const workerConfig = getWorkerConfig();
const redis = new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
});
interface RateLimitOptions {
limit?: number;
window?: number; // in milliseconds
keyGenerator?: (c: Context) => string;
}
export const rateLimiter = (options: RateLimitOptions = {}) => {
const {
limit = config.API_RATE_LIMIT,
window = config.API_RATE_WINDOW,
keyGenerator = (c) => {
const userId = c.get('userId');
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
return userId ? `rate:user:${userId}` : `rate:ip:${ip}`;
},
} = options;
return async (c: Context, next: Next) => {
const key = keyGenerator(c);
try {
// Get current count
const current = await redis.get(key);
const count = current ? parseInt(current) : 0;
// Check if limit exceeded
if (count >= limit) {
const ttl = await redis.pttl(key);
c.header('X-RateLimit-Limit', limit.toString());
c.header('X-RateLimit-Remaining', '0');
c.header('X-RateLimit-Reset', new Date(Date.now() + ttl).toISOString());
return c.json(
{
error: 'Rate limit exceeded',
retryAfter: Math.ceil(ttl / 1000),
},
429
);
}
// Increment counter
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.pexpire(key, window);
await pipeline.exec();
// Set headers
c.header('X-RateLimit-Limit', limit.toString());
c.header('X-RateLimit-Remaining', (limit - count - 1).toString());
c.header('X-RateLimit-Reset', new Date(Date.now() + window).toISOString());
return next();
} catch (error) {
logger.error('Rate limiter error:', error);
// Continue on error - don't block requests due to rate limiter failure
return next();
}
};
};