Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Per-tier rate limiter for /api/scan and other write endpoints.
|
||||
*
|
||||
* Bucket model:
|
||||
* - free: 5 scans/minute, daily cap of 5 (the daily cap is enforced
|
||||
* elsewhere in the route handler)
|
||||
* - analyst: 30 scans/minute, unlimited daily
|
||||
* - desk: 60 scans/minute, unlimited daily
|
||||
*
|
||||
* Storage:
|
||||
* - In-memory ring buffer per key.
|
||||
* - Process-local — good for single-instance Vercel deployments. For
|
||||
* multi-instance later, swap to upstash/redis without changing the
|
||||
* external interface.
|
||||
*/
|
||||
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
|
||||
type Tier = 'free' | 'analyst' | 'desk';
|
||||
|
||||
const LIMITS: Record<Tier, number> = {
|
||||
free: 5,
|
||||
analyst: 30,
|
||||
desk: 60,
|
||||
};
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
interface Bucket {
|
||||
hits: number[]; // ms timestamps
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
export function rateLimitCheck(key: string, tier: Tier): { ok: true } | { ok: false; retryAfter: number } {
|
||||
const limit = LIMITS[tier] ?? LIMITS.free;
|
||||
const now = Date.now();
|
||||
const bucket = buckets.get(key) ?? { hits: [] };
|
||||
|
||||
// Drop hits older than the window
|
||||
while (bucket.hits.length && now - bucket.hits[0] > WINDOW_MS) {
|
||||
bucket.hits.shift();
|
||||
}
|
||||
|
||||
if (bucket.hits.length >= limit) {
|
||||
const oldest = bucket.hits[0];
|
||||
const retryAfter = Math.max(1, Math.ceil((WINDOW_MS - (now - oldest)) / 1000));
|
||||
buckets.set(key, bucket);
|
||||
return { ok: false, retryAfter };
|
||||
}
|
||||
|
||||
bucket.hits.push(now);
|
||||
buckets.set(key, bucket);
|
||||
|
||||
// Opportunistic GC — keep the map small in long-running processes
|
||||
if (buckets.size > 5000 && Math.random() < 0.01) {
|
||||
pruneStale();
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function rateLimitResponse(retryAfter: number) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Slow down — you're reading faster than the model can think. Try again in a minute.",
|
||||
retryAfter,
|
||||
},
|
||||
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable key from the incoming request. Prefers the auth bearer
|
||||
* (per-user) and falls back to the forwarded IP for anonymous traffic.
|
||||
*/
|
||||
export function rateLimitKey(req: NextRequest): string {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth) return `user:${auth.slice(-32)}`;
|
||||
const fwd = req.headers.get('x-forwarded-for') || '';
|
||||
const ip = fwd.split(',')[0].trim() || 'anon';
|
||||
return `ip:${ip}`;
|
||||
}
|
||||
|
||||
function pruneStale() {
|
||||
const cutoff = Date.now() - WINDOW_MS * 2;
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (!bucket.hits.length || bucket.hits[bucket.hits.length - 1] < cutoff) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user