/** * TASK-0123 — the SHARED `ai.calls` per-user rate limiter (spec §Limits: * "30 writing calls / min per user — shares the AI cap from TASK-0122 — * implemented as one shared `ai.calls` limiter"). * * Both Ask AI chat (TASK-0122) and the writing tools count against the SAME * Redis sliding window (`ai:calls:rl:`, 30 calls / 60 s) so a user * cannot bypass the cap by mixing features. Policy is unchanged from the * chat limiter: * * - sliding zset window (zremrangebyscore → zadd → zcard → pexpire); * - degrades OPEN when Redis is unavailable (availability over * strictness — the form limiter's established policy); * - the counter is INCREMENTED on the checked call (the call itself counts * against the window, so the 31st within 60 s is the one that 429s). */ import { Inject, Injectable } from "@nestjs/common"; import { RedisService } from "../../common/redis.service.js"; import { logger } from "../../common/logger.js"; /** 30 AI calls / min per user (chat + writing combined; 429 RATE_LIMITED). */ export const AI_CALLS_PER_USER_LIMIT = 30; export const AI_CALLS_RL_WINDOW_MS = 60_000; export interface AiRateLimitResult { allowed: boolean; remaining: number; retryAfterSeconds: number; } @Injectable() export class AiRateLimitService { constructor(@Inject(RedisService) private readonly redis: RedisService) {} private lastWarn = 0; /** * Count one AI call (chat or writing) for the user; returns the window * state. Call BEFORE doing the work (the increment is the reservation). */ async check(userId: string, limit: number = AI_CALLS_PER_USER_LIMIT): Promise { const key = `ai:calls:rl:${userId}`; try { const now = Date.now(); const pipe = this.redis.getRedis().pipeline(); pipe.zremrangebyscore(key, 0, now - AI_CALLS_RL_WINDOW_MS); pipe.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`); pipe.zcard(key); pipe.pexpire(key, AI_CALLS_RL_WINDOW_MS); const results = await pipe.exec(); const card = results?.[2]?.[1]; const count = typeof card === "number" ? card : 1; const remaining = Math.max(0, limit - count); return { allowed: count <= limit, remaining, retryAfterSeconds: count <= limit ? 0 : Math.ceil(AI_CALLS_RL_WINDOW_MS / 1000), }; } catch (err) { if (Date.now() - this.lastWarn > 60_000) { this.lastWarn = Date.now(); logger.warn(`ai.calls rate limiter degraded (redis unavailable): ${(err as Error).message}`); } return { allowed: true, remaining: limit, retryAfterSeconds: 0 }; } } } /** The Redis key shape (integration tests + docs). */ export function aiCallsRlKey(userId: string): string { return `ai:calls:rl:${userId}`; }