RateLimitService implements fixed-window counters as one atomic
PostgreSQL upsert (race-safe under concurrency, proven by test), with
opportunistic sweeping of expired windows and an explicit reset for
successful-login scenarios. The global RateLimitGuard applies
@RateLimit({scope, limit, windowSeconds}) per client IP and answers
429 with Retry-After; main.ts trusts the single Caddy hop so req.ip
is the real client.
Closes #11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
64 lines
2.2 KiB
TypeScript
64 lines
2.2 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean;
|
|
retryAfterSeconds: number;
|
|
}
|
|
|
|
/**
|
|
* Fixed-window rate limiting backed by PostgreSQL (ADR 0002: no Redis).
|
|
* One atomic upsert per hit keeps concurrent requests race-safe; expired
|
|
* rows are swept opportunistically on writes.
|
|
*/
|
|
@Injectable()
|
|
export class RateLimitService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async hit(
|
|
scope: string,
|
|
key: string,
|
|
limit: number,
|
|
windowSeconds: number,
|
|
): Promise<RateLimitResult> {
|
|
const rowKey = `${scope}:${key}`;
|
|
const rows = await this.prisma.$queryRaw<{ count: number; window_start: Date }[]>`
|
|
INSERT INTO rate_limits (key, window_start, count)
|
|
VALUES (${rowKey}, now(), 1)
|
|
ON CONFLICT (key) DO UPDATE SET
|
|
count = CASE
|
|
WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds})
|
|
THEN 1 ELSE rate_limits.count + 1 END,
|
|
window_start = CASE
|
|
WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds})
|
|
THEN now() ELSE rate_limits.window_start END
|
|
RETURNING count, window_start
|
|
`;
|
|
const row = rows[0];
|
|
if (!row) return { allowed: true, retryAfterSeconds: 0 };
|
|
|
|
const elapsedMs = Date.now() - row.window_start.getTime();
|
|
const retryAfterSeconds = Math.max(1, Math.ceil(windowSeconds - elapsedMs / 1000));
|
|
|
|
// Opportunistic sweep (~1% of hits) so the table stays small without
|
|
// needing a scheduler this early.
|
|
if (Math.random() < 0.01) void this.cleanupExpired().catch(() => undefined);
|
|
|
|
return { allowed: row.count <= limit, retryAfterSeconds };
|
|
}
|
|
|
|
/** Deletes counters whose window ended more than a day ago. */
|
|
async cleanupExpired(): Promise<number> {
|
|
const result = await this.prisma.rateLimit.deleteMany({
|
|
where: { windowStart: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
|
|
});
|
|
return result.count;
|
|
}
|
|
|
|
/** Clears one counter — used when an action should reset it (e.g. successful login). */
|
|
async reset(scope: string, key: string): Promise<void> {
|
|
await this.prisma.rateLimit.deleteMany({ where: { key: `${scope}:${key}` } });
|
|
}
|
|
}
|