← vibesdk
worker/services/rate-limit/DORateLimitStore.ts
8.4 KB
Download file
import { DurableObject } from 'cloudflare:workers';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function getStartOfUtcDay(nowMs: number): number {
return Math.floor(nowMs / MS_PER_DAY) * MS_PER_DAY;
}
export interface RateLimitBucket {
count: number;
timestamp: number;
}
export interface RateLimitState {
buckets: Map<string, RateLimitBucket>;
lastCleanup: number;
}
export interface RateLimitConfig {
limit: number;
period: number; // in seconds
burst?: number;
burstWindow?: number; // in seconds
bucketSize?: number; // in seconds
dailyLimit?: number; // max requests in a rolling 24h window
/** If true, the main window is aligned to UTC calendar day (resets at midnight UTC) */
calendarDaily?: boolean;
}
export interface RateLimitResult {
success: boolean;
remainingLimit?: number;
exceededLimit?: 'main' | 'burst' | 'daily';
limitValue?: number;
periodSeconds?: number;
}
/**
* DORateLimitStore - Durable Object-based rate limiting store
*
* Provides distributed rate limiting using bucketed sliding window algorithm
* similar to the KV implementation but with better scalability, consistency and cost-effectiveness
*/
export class DORateLimitStore extends DurableObject<Env> {
private state: RateLimitState = {
buckets: new Map(),
lastCleanup: Date.now()
};
private initialized = false;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}
async increment(key: string, config: RateLimitConfig, incrementBy: number = 1): Promise<RateLimitResult> {
await this.ensureInitialized();
const now = Date.now();
const bucketSize = (config.bucketSize || 10) * 1000; // Convert to milliseconds
const burstWindow = (config.burstWindow || 60) * 1000; // Convert to milliseconds
const mainWindow = config.period * 1000; // Convert to milliseconds
const dailyWindow = config.dailyLimit ? 24 * 60 * 60 * 1000 : 0; // 24 hours in ms if enabled
const mainWindowStart = config.calendarDaily ? getStartOfUtcDay(now) : now - mainWindow;
const currentBucket = Math.floor(now / bucketSize) * bucketSize;
const bucketKey = `${key}:${currentBucket}`;
// Periodic cleanup every 5 minutes
if (now - this.state.lastCleanup > 5 * 60 * 1000) {
await this.cleanup(now, Math.max(mainWindow, burstWindow, dailyWindow));
}
// Calculate current counts
const mainBuckets = this.getBucketsInRange(key, mainWindowStart, now, bucketSize);
const burstBuckets = config.burst ? this.getBucketsInWindow(key, now, burstWindow, bucketSize) : [];
const dailyBuckets = config.dailyLimit ? this.getBucketsInWindow(key, now, dailyWindow, bucketSize) : [];
const mainCount = mainBuckets.reduce((sum, bucket) => sum + bucket.count, 0);
const burstCount = burstBuckets.reduce((sum, bucket) => sum + bucket.count, 0);
const dailyCount = dailyBuckets.reduce((sum, bucket) => sum + bucket.count, 0);
// Check limits
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
};
}
if (config.dailyLimit && dailyCount >= config.dailyLimit) {
return {
success: false,
remainingLimit: 0,
exceededLimit: 'daily',
limitValue: config.dailyLimit,
periodSeconds: 24 * 60 * 60
};
}
// Increment current bucket
const existing = this.state.buckets.get(bucketKey);
const newCount = (existing?.count || 0) + incrementBy;
this.state.buckets.set(bucketKey, {
count: newCount,
timestamp: now
});
await this.persistState();
// Compute remaining across applicable windows
const mainRemaining = config.limit - mainCount - incrementBy;
const dailyRemaining = config.dailyLimit != null ? (config.dailyLimit - dailyCount - incrementBy) : undefined;
const remaining = dailyRemaining != null ? Math.min(mainRemaining, dailyRemaining) : mainRemaining;
return {
success: true,
remainingLimit: Math.max(0, remaining)
};
}
async getRemainingLimit(key: string, config: RateLimitConfig): Promise<number> {
await this.ensureInitialized();
const now = Date.now();
const bucketSize = (config.bucketSize || 10) * 1000;
const mainWindow = config.period * 1000;
const dailyWindow = config.dailyLimit ? 24 * 60 * 60 * 1000 : 0;
const mainWindowStart = config.calendarDaily ? getStartOfUtcDay(now) : now - mainWindow;
const mainBuckets = this.getBucketsInRange(key, mainWindowStart, now, bucketSize);
const mainCount = mainBuckets.reduce((sum, bucket) => sum + bucket.count, 0);
const mainRemaining = config.limit - mainCount;
if (config.dailyLimit) {
const dailyBuckets = this.getBucketsInWindow(key, now, dailyWindow, bucketSize);
const dailyCount = dailyBuckets.reduce((sum, bucket) => sum + bucket.count, 0);
const dailyRemaining = config.dailyLimit - dailyCount;
return Math.max(0, Math.min(mainRemaining, dailyRemaining));
}
return Math.max(0, mainRemaining);
}
async resetLimit(key?: string): Promise<void> {
await this.ensureInitialized();
if (key) {
// Reset specific key buckets
const keysToDelete = Array.from(this.state.buckets.keys())
.filter(bucketKey => bucketKey.startsWith(`${key}:`));
for (const bucketKey of keysToDelete) {
this.state.buckets.delete(bucketKey);
}
} else {
// Reset all buckets
this.state.buckets.clear();
}
await this.persistState();
}
private getBucketsInWindow(key: string, now: number, windowMs: number, bucketSizeMs: number): RateLimitBucket[] {
return this.getBucketsInRange(key, now - windowMs, now, bucketSizeMs);
}
private getBucketsInRange(key: string, startMs: number, endMs: number, bucketSizeMs: number): RateLimitBucket[] {
const buckets: RateLimitBucket[] = [];
for (let time = Math.floor(startMs / bucketSizeMs) * bucketSizeMs; time <= endMs; time += bucketSizeMs) {
const bucketKey = `${key}:${time}`;
const bucket = this.state.buckets.get(bucketKey);
if (bucket) {
buckets.push(bucket);
}
}
return buckets;
}
private async cleanup(now: number, maxWindow: number): Promise<void> {
const cutoff = now - maxWindow;
let needsUpdate = false;
for (const [bucketKey, bucket] of this.state.buckets) {
if (bucket.timestamp < cutoff) {
this.state.buckets.delete(bucketKey);
needsUpdate = true;
}
}
if (needsUpdate) {
this.state.lastCleanup = now;
await this.persistState();
}
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
const stored = await this.ctx.storage.get<{
buckets: [string, RateLimitBucket][];
lastCleanup: number;
}>('state');
if (stored) {
this.state = {
buckets: new Map(stored.buckets),
lastCleanup: stored.lastCleanup
};
}
this.initialized = true;
}
}
private async persistState(): Promise<void> {
await this.ctx.storage.put('state', {
buckets: Array.from(this.state.buckets.entries()),
lastCleanup: this.state.lastCleanup
});
}
}