Advertisement

← vibesdk

worker/services/rate-limit/KVRateLimitStore.ts

4.2 KB Download file
import { createObjectLogger } from '../../logger';
import { KVRateLimitConfig } from './config';
import type { RateLimitResult } from './DORateLimitStore';

export class KVRateLimitStore {
	static logger = createObjectLogger(this, 'KVRateLimitStore');

	static async increment(
		kv: KVNamespace,
		key: string,
		config: KVRateLimitConfig,
		incrementBy: number = 1
	): Promise<RateLimitResult> {
		const now = Date.now();

		const bucketSize = (config.bucketSize ?? 10) * 1000; // ms
		const burstWindow = (config.burstWindow ?? 60) * 1000; // ms
		const mainWindow = config.period * 1000; // ms

		const currentBucket = Math.floor(now / bucketSize) * bucketSize;

		try {
			const mainBuckets = this.generateBucketKeys(key, now, mainWindow, bucketSize);
			const burstBuckets = config.burst
				? this.generateBucketKeys(key, now, burstWindow, bucketSize)
				: [];

			const allBucketKeys = [...new Set([...mainBuckets, ...burstBuckets])];
			const bucketResults = await Promise.all(
				allBucketKeys.map(async (bucketKey) => {
					const value = await kv.get(bucketKey);
					return { key: bucketKey, count: value ? parseInt(value, 10) || 0 : 0 };
				})
			);

			const bucketMap = new Map(bucketResults.map((r) => [r.key, r.count]));

			const mainCount = mainBuckets.reduce((sum, bucketKey) => sum + (bucketMap.get(bucketKey) || 0), 0);
			const burstCount = burstBuckets.reduce((sum, bucketKey) => sum + (bucketMap.get(bucketKey) || 0), 0);

			if (mainCount >= config.limit) {
				return {
					success: false,
					remainingLimit: 0,
					exceededLimit: 'main',
					limitValue: config.limit,
					periodSeconds: config.period
				};
			}

			if (config.burst && burstCount >= config.burst) {
				return {
					success: false,
					remainingLimit: 0,
					exceededLimit: 'burst',
					limitValue: config.burst,
					periodSeconds: config.burstWindow
				};
			}

			const currentBucketKey = `ratelimit:${key}:${currentBucket}`;
			const maxTtlSeconds = Math.max(config.period, config.burstWindow ?? 60) + (config.bucketSize ?? 10);

			await this.incrementBucketWithRetry(kv, currentBucketKey, maxTtlSeconds, incrementBy);

			return { success: true, remainingLimit: Math.max(0, config.limit - mainCount - incrementBy) };
		} catch (error) {
			this.logger.error('Failed to enforce KV rate limit', {
				key,
				error: error instanceof Error ? error.message : 'Unknown error',
			});
			// Fail open
			return { success: true };
		}
	}

	static async getRemainingLimit(
		kv: KVNamespace,
		key: string,
		config: KVRateLimitConfig
	): Promise<number> {
		const now = Date.now();
		const bucketSize = (config.bucketSize ?? 10) * 1000; // ms
		const mainWindow = config.period * 1000; // ms

		const mainBuckets = this.generateBucketKeys(key, now, mainWindow, bucketSize);
		const counts = await Promise.all(
			mainBuckets.map(async (bucketKey) => {
				const value = await kv.get(bucketKey);
				return value ? parseInt(value, 10) || 0 : 0;
			})
		);
		const mainCount = counts.reduce((sum, c) => sum + c, 0);
		return Math.max(0, config.limit - mainCount);
	}

	private static generateBucketKeys(key: string, now: number, windowMs: number, bucketSizeMs: number): string[] {
		const buckets: string[] = [];
		const windowStart = now - windowMs;

		for (let time = Math.floor(windowStart / bucketSizeMs) * bucketSizeMs; time <= now; time += bucketSizeMs) {
			buckets.push(`ratelimit:${key}:${time}`);
		}

		return buckets;
	}

	private static async incrementBucketWithRetry(
		kv: KVNamespace,
		bucketKey: string,
		ttlSeconds: number,
		maxRetries: number = 3,
        incrementBy: number = 1
	): Promise<void> {
		for (let attempt = 0; attempt < maxRetries; attempt++) {
			try {
				const current = await kv.get(bucketKey);
				const newCount = (current ? parseInt(current, 10) : 0) + incrementBy;
				await kv.put(bucketKey, newCount.toString(), { expirationTtl: ttlSeconds });
				return;
			} catch (error) {
				if (error instanceof Error && error.message.includes('429') && attempt < maxRetries - 1) {
					await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100));
					continue;
				}
				throw error;
			}
		}
	}
}