143 lines
4.9 KiB
TypeScript
143 lines
4.9 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
|
|
/**
|
|
* Rate limiting for the public API surface.
|
|
*
|
|
* `/api/chat` is reachable by anyone on the internet, takes no credentials,
|
|
* and spends AWS Bedrock credit on every call (understanding + generation +
|
|
* entailment, several model calls per turn) against a small personal budget.
|
|
* The architecture assigns rate limiting to `api-gateway`, which is not built
|
|
* yet, so until it exists this is the only place the limit can live.
|
|
*
|
|
* It runs here rather than inside the route handlers because the edge
|
|
* middleware rejects an abusive request before any handler work — and because
|
|
* it covers every current and future `/api/*` route by default rather than
|
|
* one endpoint at a time.
|
|
*
|
|
* Known limitations, stated rather than hidden:
|
|
* - Counters are per process and in memory. Production runs a single `web`
|
|
* container, so this is a real limit today; the moment that scales to more
|
|
* than one replica each replica gets its own allowance, and this needs to
|
|
* move to Redis (already reserved for exactly this in `docs/architecture.md`)
|
|
* or to the gateway.
|
|
* - It is keyed by client IP, so it throttles a shared NAT as one caller. That
|
|
* is the correct trade for a cost guard with no authentication; per-user
|
|
* limits need auth, which does not exist yet.
|
|
* - It is a cost and abuse guard, not a security control. It does not
|
|
* authenticate anyone and must not be described as if it does.
|
|
*/
|
|
|
|
interface Bucket {
|
|
hits: number[];
|
|
}
|
|
|
|
interface Rule {
|
|
windowMs: number;
|
|
max: number;
|
|
}
|
|
|
|
// Chat is the expensive path: several Bedrock calls per request, and a single
|
|
// turn was measured taking up to ~45s of model time. Autocomplete is a local
|
|
// catalog lookup with no model call, so it can be far more generous without
|
|
// costing anything.
|
|
const RULES: Array<{ prefix: string; rules: Rule[] }> = [
|
|
{
|
|
prefix: "/api/chat",
|
|
rules: [
|
|
{ windowMs: 60_000, max: 12 },
|
|
{ windowMs: 3_600_000, max: 120 },
|
|
],
|
|
},
|
|
{
|
|
prefix: "/api/suggest",
|
|
rules: [{ windowMs: 60_000, max: 120 }],
|
|
},
|
|
];
|
|
|
|
const buckets = new Map<string, Bucket>();
|
|
const LONGEST_WINDOW_MS = 3_600_000;
|
|
let lastSweep = 0;
|
|
|
|
/** Drop entries no rule can still be counting, so the map cannot grow without bound. */
|
|
function sweep(now: number) {
|
|
if (now - lastSweep < 60_000) return;
|
|
lastSweep = now;
|
|
for (const [key, bucket] of buckets) {
|
|
const live = bucket.hits.filter((t) => now - t < LONGEST_WINDOW_MS);
|
|
if (live.length === 0) buckets.delete(key);
|
|
else bucket.hits = live;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Caddy sits in front and sets `X-Forwarded-For`; the left-most entry is the
|
|
* original client. Falling back to a shared key rather than to "unlimited"
|
|
* matters: an unknown IP must not become a way to opt out of the limit.
|
|
*/
|
|
function clientKey(request: NextRequest): string {
|
|
const forwarded = request.headers.get("x-forwarded-for");
|
|
if (forwarded) {
|
|
const first = forwarded.split(",")[0]?.trim();
|
|
if (first) return first;
|
|
}
|
|
return request.headers.get("x-real-ip")?.trim() || "unknown";
|
|
}
|
|
|
|
function matchRules(pathname: string) {
|
|
return RULES.find((entry) => pathname.startsWith(entry.prefix))?.rules;
|
|
}
|
|
|
|
export function middleware(request: NextRequest) {
|
|
const rules = matchRules(request.nextUrl.pathname);
|
|
if (!rules) return NextResponse.next();
|
|
|
|
const now = Date.now();
|
|
sweep(now);
|
|
|
|
const key = `${clientKey(request)}:${request.nextUrl.pathname}`;
|
|
const bucket = buckets.get(key) ?? { hits: [] };
|
|
bucket.hits = bucket.hits.filter((t) => now - t < LONGEST_WINDOW_MS);
|
|
|
|
for (const rule of rules) {
|
|
const inWindow = bucket.hits.filter((t) => now - t < rule.windowMs);
|
|
if (inWindow.length >= rule.max) {
|
|
const oldest = Math.min(...inWindow);
|
|
const retryAfterSec = Math.max(1, Math.ceil((rule.windowMs - (now - oldest)) / 1000));
|
|
// Record nothing for a rejected request: a client hammering the endpoint
|
|
// should not keep pushing its own window forward and lock itself out for
|
|
// longer than the rule says.
|
|
buckets.set(key, bucket);
|
|
return NextResponse.json(
|
|
{
|
|
error: "rate_limited",
|
|
message:
|
|
"Bạn đang gửi quá nhiều yêu cầu trong thời gian ngắn. Vui lòng đợi một lát rồi thử lại.",
|
|
},
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
"Retry-After": String(retryAfterSec),
|
|
"X-RateLimit-Limit": String(rule.max),
|
|
"X-RateLimit-Remaining": "0",
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
bucket.hits.push(now);
|
|
buckets.set(key, bucket);
|
|
|
|
const tightest = rules[0];
|
|
const used = bucket.hits.filter((t) => now - t < tightest.windowMs).length;
|
|
const response = NextResponse.next();
|
|
response.headers.set("X-RateLimit-Limit", String(tightest.max));
|
|
response.headers.set("X-RateLimit-Remaining", String(Math.max(0, tightest.max - used)));
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/api/:path*"],
|
|
};
|