import { FetchaError, PLAN_LIMITS } from "@fetcha/core"; import { db, sql, usageEvents } from "@fetcha/db"; import type { ApiPrincipal } from "./auth"; import { getKV } from "./redis"; const CONCURRENCY_TTL_SEC = 150; // safety net if a release is lost /** Reserve a concurrency slot for the organization. Returns a release function. */ export async function acquireConcurrency(p: ApiPrincipal): Promise<() => Promise> { const kv = getKV(); const limit = PLAN_LIMITS[p.plan].concurrency; const key = `fch:conc:${p.organizationId}`; const n = await kv.incrWithTtl(key, CONCURRENCY_TTL_SEC); if (n > limit) { await kv.decr(key); throw new FetchaError("CONCURRENCY_LIMIT", `Your plan allows ${limit} concurrent requests.`, { details: { limit } }); } let released = false; return async () => { if (released) return; released = true; await kv.decr(key).catch(() => {}); }; } /** Reserve a managed-browser slot for the organization (separate, smaller pool than plain fetches). */ export async function acquireBrowserSlot(p: ApiPrincipal): Promise<() => Promise> { const kv = getKV(); const limit = PLAN_LIMITS[p.plan].browser_concurrency; const key = `fch:bconc:${p.organizationId}`; const n = await kv.incrWithTtl(key, CONCURRENCY_TTL_SEC); if (n > limit) { await kv.decr(key); throw new FetchaError("CONCURRENCY_LIMIT", `At most ${limit} browser renders can run at once for your organization.`, { details: { limit, scope: "browser" } }); } let released = false; return async () => { if (released) return; released = true; await kv.decr(key).catch(() => {}); }; } /** Sliding-window rate limits: per key (burst), per organization (sustained), per IP (anonymous abuse). */ export async function checkRateLimits(p: ApiPrincipal, ip: string | null): Promise { const kv = getKV(); const limits = PLAN_LIMITS[p.plan]; const perMinute = Math.max(60, limits.concurrency * 60); // generous: concurrency is the real cap const checks: Array> = [ kv.slidingWindow(`fch:rl:org:${p.organizationId}`, 60_000, perMinute), ]; if (p.keyId) checks.push(kv.slidingWindow(`fch:rl:key:${p.keyId}`, 1_000, Math.max(10, limits.concurrency * 2))); if (ip) checks.push(kv.slidingWindow(`fch:rl:ip:${ip}`, 1_000, 200)); const results = await Promise.all(checks); const blocked = results.find((r) => !r.allowed); if (blocked) throw new FetchaError("RATE_LIMITED", undefined, { details: { retry_after_ms: blocked.retryAfterMs } }); } export interface MonthlyUsage { requests: number; spendUsd: number; projectRequests: number; projectSpendUsd: number; } export async function monthlyUsage(p: ApiPrincipal): Promise { const kv = getKV(); const cacheKey = `fch:usage:${p.organizationId}:${p.projectId}`; const cached = await kv.get(cacheKey).catch(() => null); if (cached) return JSON.parse(cached) as MonthlyUsage; const start = new Date(); start.setUTCDate(1); start.setUTCHours(0, 0, 0, 0); const [org] = await db .select({ requests: sql`coalesce(sum(case when ${usageEvents.metric} = 'request' then ${usageEvents.quantity} else 0 end), 0)::float`, spend: sql`coalesce(sum(${usageEvents.costUsd}), 0)::float`, }) .from(usageEvents) .where(sql`${usageEvents.organizationId} = ${p.organizationId} and ${usageEvents.createdAt} >= ${start}`); const [proj] = await db .select({ requests: sql`coalesce(sum(case when ${usageEvents.metric} = 'request' then ${usageEvents.quantity} else 0 end), 0)::float`, spend: sql`coalesce(sum(${usageEvents.costUsd}), 0)::float`, }) .from(usageEvents) .where(sql`${usageEvents.projectId} = ${p.projectId} and ${usageEvents.createdAt} >= ${start}`); const usage: MonthlyUsage = { requests: Number(org?.requests ?? 0), spendUsd: Number(org?.spend ?? 0), projectRequests: Number(proj?.requests ?? 0), projectSpendUsd: Number(proj?.spend ?? 0) }; await kv.set(cacheKey, JSON.stringify(usage), 20).catch(() => {}); return usage; } /** Enforce plan quota and hard spending limits. Soft limits are handled by the alerting job. */ export async function checkMonthlyLimits(p: ApiPrincipal): Promise { const usage = await monthlyUsage(p); const limits = PLAN_LIMITS[p.plan]; if (usage.requests >= limits.monthly_requests) { throw new FetchaError("USAGE_LIMIT_REACHED", `Monthly request quota of ${limits.monthly_requests.toLocaleString("en-US")} reached for the ${limits.label} plan.`, { details: { limit: limits.monthly_requests, used: usage.requests }, }); } if (p.projectMonthlyRequestLimit && usage.projectRequests >= p.projectMonthlyRequestLimit) { throw new FetchaError("USAGE_LIMIT_REACHED", "Project monthly request limit reached.", { details: { limit: p.projectMonthlyRequestLimit, used: usage.projectRequests } }); } if (p.orgHardLimitUsd !== null && usage.spendUsd >= p.orgHardLimitUsd) { throw new FetchaError("USAGE_LIMIT_REACHED", "Organization hard spending limit reached.", { details: { limit_usd: p.orgHardLimitUsd, spent_usd: usage.spendUsd } }); } if (p.projectHardLimitUsd !== null && usage.projectSpendUsd >= p.projectHardLimitUsd) { throw new FetchaError("USAGE_LIMIT_REACHED", "Project hard spending limit reached.", { details: { limit_usd: p.projectHardLimitUsd, spent_usd: usage.projectSpendUsd } }); } return usage; } export async function invalidateUsageCache(organizationId: string, projectId: string): Promise { await getKV().del(`fch:usage:${organizationId}:${projectId}`).catch(() => {}); }