1import { FetchaError, PLAN_LIMITS } from "@fetcha/core";2import { db, sql, usageEvents } from "@fetcha/db";3import type { ApiPrincipal } from "./auth";4import { getKV } from "./redis";56const CONCURRENCY_TTL_SEC = 150; // safety net if a release is lost78/** Reserve a concurrency slot for the organization. Returns a release function. */9export async function acquireConcurrency(p: ApiPrincipal): Promise<() => Promise<void>> {10 const kv = getKV();11 const limit = PLAN_LIMITS[p.plan].concurrency;12 const key = `fch:conc:${p.organizationId}`;13 const n = await kv.incrWithTtl(key, CONCURRENCY_TTL_SEC);14 if (n > limit) {15 await kv.decr(key);16 throw new FetchaError("CONCURRENCY_LIMIT", `Your plan allows ${limit} concurrent requests.`, { details: { limit } });17 }18 let released = false;19 return async () => {20 if (released) return;21 released = true;22 await kv.decr(key).catch(() => {});23 };24}2526/** Sliding-window rate limits: per key (burst), per organization (sustained), per IP (anonymous abuse). */27export async function checkRateLimits(p: ApiPrincipal, ip: string | null): Promise<void> {28 const kv = getKV();29 const limits = PLAN_LIMITS[p.plan];30 const perMinute = Math.max(60, limits.concurrency * 60); // generous: concurrency is the real cap31 const checks: Array<Promise<{ allowed: boolean; retryAfterMs: number }>> = [32 kv.slidingWindow(`fch:rl:org:${p.organizationId}`, 60_000, perMinute),33 ];34 if (p.keyId) checks.push(kv.slidingWindow(`fch:rl:key:${p.keyId}`, 1_000, Math.max(10, limits.concurrency * 2)));35 if (ip) checks.push(kv.slidingWindow(`fch:rl:ip:${ip}`, 1_000, 200));36 const results = await Promise.all(checks);37 const blocked = results.find((r) => !r.allowed);38 if (blocked) throw new FetchaError("RATE_LIMITED", undefined, { details: { retry_after_ms: blocked.retryAfterMs } });39}4041export interface MonthlyUsage {42 requests: number;43 spendUsd: number;44 projectRequests: number;45 projectSpendUsd: number;46}4748export async function monthlyUsage(p: ApiPrincipal): Promise<MonthlyUsage> {49 const kv = getKV();50 const cacheKey = `fch:usage:${p.organizationId}:${p.projectId}`;51 const cached = await kv.get(cacheKey).catch(() => null);52 if (cached) return JSON.parse(cached) as MonthlyUsage;53 const start = new Date();54 start.setUTCDate(1);55 start.setUTCHours(0, 0, 0, 0);56 const [org] = await db57 .select({58 requests: sql<number>`coalesce(sum(case when ${usageEvents.metric} = 'request' then ${usageEvents.quantity} else 0 end), 0)::float`,59 spend: sql<number>`coalesce(sum(${usageEvents.costUsd}), 0)::float`,60 })61 .from(usageEvents)62 .where(sql`${usageEvents.organizationId} = ${p.organizationId} and ${usageEvents.createdAt} >= ${start}`);63 const [proj] = await db64 .select({65 requests: sql<number>`coalesce(sum(case when ${usageEvents.metric} = 'request' then ${usageEvents.quantity} else 0 end), 0)::float`,66 spend: sql<number>`coalesce(sum(${usageEvents.costUsd}), 0)::float`,67 })68 .from(usageEvents)69 .where(sql`${usageEvents.projectId} = ${p.projectId} and ${usageEvents.createdAt} >= ${start}`);70 const usage: MonthlyUsage = { requests: Number(org?.requests ?? 0), spendUsd: Number(org?.spend ?? 0), projectRequests: Number(proj?.requests ?? 0), projectSpendUsd: Number(proj?.spend ?? 0) };71 await kv.set(cacheKey, JSON.stringify(usage), 20).catch(() => {});72 return usage;73}7475/** Enforce plan quota and hard spending limits. Soft limits are handled by the alerting job. */76export async function checkMonthlyLimits(p: ApiPrincipal): Promise<MonthlyUsage> {77 const usage = await monthlyUsage(p);78 const limits = PLAN_LIMITS[p.plan];79 if (usage.requests >= limits.monthly_requests) {80 throw new FetchaError("USAGE_LIMIT_REACHED", `Monthly request quota of ${limits.monthly_requests.toLocaleString("en-US")} reached for the ${limits.label} plan.`, {81 details: { limit: limits.monthly_requests, used: usage.requests },82 });83 }84 if (p.projectMonthlyRequestLimit && usage.projectRequests >= p.projectMonthlyRequestLimit) {85 throw new FetchaError("USAGE_LIMIT_REACHED", "Project monthly request limit reached.", { details: { limit: p.projectMonthlyRequestLimit, used: usage.projectRequests } });86 }87 if (p.orgHardLimitUsd !== null && usage.spendUsd >= p.orgHardLimitUsd) {88 throw new FetchaError("USAGE_LIMIT_REACHED", "Organization hard spending limit reached.", { details: { limit_usd: p.orgHardLimitUsd, spent_usd: usage.spendUsd } });89 }90 if (p.projectHardLimitUsd !== null && usage.projectSpendUsd >= p.projectHardLimitUsd) {91 throw new FetchaError("USAGE_LIMIT_REACHED", "Project hard spending limit reached.", { details: { limit_usd: p.projectHardLimitUsd, spent_usd: usage.projectSpendUsd } });92 }93 return usage;94}9596export async function invalidateUsageCache(organizationId: string, projectId: string): Promise<void> {97 await getKV().del(`fch:usage:${organizationId}:${projectId}`).catch(() => {});98}99