94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
import { getPlanFeatureUsage } from "./.server/subscriptionService";
|
|
import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures";
|
|
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
|
|
import { TenantSubscriptionWithDetails } from "../db/tenantSubscriptions.db.server";
|
|
import { createFixedWindowRateLimiter, type SharedStore } from "@hivegrid/shared-store";
|
|
import { getSharedStore } from "~/shared-store.server";
|
|
|
|
const DEFAULT_RATE_LIMIT_PER_MINUTE = 60;
|
|
const DEFAULT_RATE_LIMIT_PER_SECOND = 5;
|
|
|
|
let storeFactory: () => SharedStore = () => getSharedStore();
|
|
|
|
function featureLimit(feature: Awaited<ReturnType<typeof getPlanFeatureUsage>> | null, fallback: number): number {
|
|
if (!feature) return fallback;
|
|
if (feature.type === SubscriptionFeatureLimitType.UNLIMITED) return 0;
|
|
return feature.value;
|
|
}
|
|
|
|
export async function rateLimitByKey({
|
|
apiKey,
|
|
tenantSubscription,
|
|
tenantId,
|
|
limitByMinute,
|
|
limitBySecond,
|
|
}: {
|
|
apiKey: string;
|
|
tenantSubscription: TenantSubscriptionWithDetails | null;
|
|
tenantId: string;
|
|
limitByMinute?: number;
|
|
limitBySecond?: number;
|
|
}): Promise<{
|
|
success: boolean;
|
|
error: string | null;
|
|
}> {
|
|
const [minuteFeature, secondFeature] = await Promise.all([
|
|
limitByMinute === undefined ? getPlanFeatureUsage(tenantId, DefaultFeatures.RateLimitPerMinute, tenantSubscription) : Promise.resolve(null),
|
|
limitBySecond === undefined ? getPlanFeatureUsage(tenantId, DefaultFeatures.RateLimitPerSecond, tenantSubscription) : Promise.resolve(null),
|
|
]);
|
|
const minuteLimit = limitByMinute ?? featureLimit(minuteFeature, DEFAULT_RATE_LIMIT_PER_MINUTE);
|
|
const secondLimit = limitBySecond ?? featureLimit(secondFeature, DEFAULT_RATE_LIMIT_PER_SECOND);
|
|
|
|
try {
|
|
const store = storeFactory();
|
|
const [minuteDecision, secondDecision] = await Promise.all([
|
|
minuteLimit === 0
|
|
? Promise.resolve(null)
|
|
: createFixedWindowRateLimiter({
|
|
store,
|
|
prefix: "legacy-api-minute",
|
|
windowSeconds: 60,
|
|
max: minuteLimit,
|
|
}).consume(apiKey),
|
|
secondLimit === 0
|
|
? Promise.resolve(null)
|
|
: createFixedWindowRateLimiter({
|
|
store,
|
|
prefix: "legacy-api-second",
|
|
windowSeconds: 1,
|
|
max: secondLimit,
|
|
}).consume(apiKey),
|
|
]);
|
|
|
|
if (minuteDecision && !minuteDecision.allowed) {
|
|
const retryAfterSeconds = Math.max(1, Math.ceil((minuteDecision.resetAt.getTime() - Date.now()) / 1000));
|
|
return {
|
|
success: false,
|
|
error: `You've exceeded the ${minuteLimit} request/min rate limit, try again in ${retryAfterSeconds} seconds`,
|
|
};
|
|
}
|
|
|
|
if (secondDecision && !secondDecision.allowed) {
|
|
return {
|
|
success: false,
|
|
error: `You've exceeded the ${secondLimit} request/sec rate limit`,
|
|
};
|
|
}
|
|
} catch {
|
|
// A replica-local fallback would make the advertised limit bypassable by
|
|
// round-robin traffic. Authentication/usage enforcement therefore fails
|
|
// closed while the shared counter is unavailable.
|
|
return { success: false, error: "Rate limit service temporarily unavailable" };
|
|
}
|
|
|
|
return { success: true, error: null };
|
|
}
|
|
|
|
export function __setRateLimitStoreForTests(store: SharedStore): void {
|
|
storeFactory = () => store;
|
|
}
|
|
|
|
export function __resetRateLimitStoreForTests(): void {
|
|
storeFactory = () => getSharedStore();
|
|
}
|