Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/services/rateLimitService.ts
T

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();
}