feat(rate-limits): make rate limits configurable via environment variables (#892)

* feat(rate-limits): make rate limits configurable via environment variables

* add defaults for CI
This commit is contained in:
Waleed Latif
2025-08-06 20:56:23 -07:00
committed by GitHub
parent f94258ef83
commit 7461ddf8f7
5 changed files with 69 additions and 28 deletions

View File

@@ -110,6 +110,18 @@ export const env = createEnv({
// Data Retention
FREE_PLAN_LOG_RETENTION_DAYS: z.string().optional(), // Log retention days for free plan users
// Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'), // Manual execution bypass value (effectively unlimited)
RATE_LIMIT_FREE_SYNC: z.string().optional().default('10'), // Free tier sync API executions per minute
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('50'), // Free tier async API executions per minute
RATE_LIMIT_PRO_SYNC: z.string().optional().default('25'), // Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('200'), // Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('75'), // Team tier sync API executions per minute
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('500'), // Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('150'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('1000'), // Enterprise tier async API executions per minute
// Real-time Communication
SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features
SOCKET_PORT: z.number().optional(), // Port for WebSocket server

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RateLimiter } from '@/services/queue/RateLimiter'
import { RATE_LIMITS } from '@/services/queue/types'
import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS } from '@/services/queue/types'
// Mock the database module
vi.mock('@/db', () => ({
@@ -34,7 +34,7 @@ describe('RateLimiter', () => {
const result = await rateLimiter.checkRateLimit(testUserId, 'free', 'manual', false)
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(999999)
expect(result.remaining).toBe(MANUAL_EXECUTION_LIMIT)
expect(result.resetAt).toBeInstanceOf(Date)
expect(db.select).not.toHaveBeenCalled()
})
@@ -144,8 +144,8 @@ describe('RateLimiter', () => {
const status = await rateLimiter.getRateLimitStatus(testUserId, 'free', 'manual', false)
expect(status.used).toBe(0)
expect(status.limit).toBe(999999)
expect(status.remaining).toBe(999999)
expect(status.limit).toBe(MANUAL_EXECUTION_LIMIT)
expect(status.remaining).toBe(MANUAL_EXECUTION_LIMIT)
expect(status.resetAt).toBeInstanceOf(Date)
})

View File

@@ -2,7 +2,13 @@ import { eq, sql } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { userRateLimits } from '@/db/schema'
import { RATE_LIMITS, type SubscriptionPlan, type TriggerType } from '@/services/queue/types'
import {
MANUAL_EXECUTION_LIMIT,
RATE_LIMIT_WINDOW_MS,
RATE_LIMITS,
type SubscriptionPlan,
type TriggerType,
} from '@/services/queue/types'
const logger = createLogger('RateLimiter')
@@ -21,8 +27,8 @@ export class RateLimiter {
if (triggerType === 'manual') {
return {
allowed: true,
remaining: 999999,
resetAt: new Date(Date.now() + 60000),
remaining: MANUAL_EXECUTION_LIMIT,
resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS),
}
}
@@ -32,7 +38,7 @@ export class RateLimiter {
: limit.syncApiExecutionsPerMinute
const now = new Date()
const windowStart = new Date(now.getTime() - 60000) // 1 minute ago
const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS)
// Get or create rate limit record
const [rateLimitRecord] = await db
@@ -78,7 +84,9 @@ export class RateLimiter {
// Check if we exceeded the limit
if (actualCount > execLimit) {
const resetAt = new Date(new Date(insertedRecord.windowStart).getTime() + 60000)
const resetAt = new Date(
new Date(insertedRecord.windowStart).getTime() + RATE_LIMIT_WINDOW_MS
)
await db
.update(userRateLimits)
@@ -98,7 +106,7 @@ export class RateLimiter {
return {
allowed: true,
remaining: execLimit - actualCount,
resetAt: new Date(new Date(insertedRecord.windowStart).getTime() + 60000),
resetAt: new Date(new Date(insertedRecord.windowStart).getTime() + RATE_LIMIT_WINDOW_MS),
}
}
@@ -124,7 +132,9 @@ export class RateLimiter {
// Check if we exceeded the limit AFTER the atomic increment
if (actualNewRequests > execLimit) {
const resetAt = new Date(new Date(rateLimitRecord.windowStart).getTime() + 60000)
const resetAt = new Date(
new Date(rateLimitRecord.windowStart).getTime() + RATE_LIMIT_WINDOW_MS
)
logger.info(
`Rate limit exceeded - request ${actualNewRequests} > limit ${execLimit} for user ${userId}`,
@@ -154,7 +164,7 @@ export class RateLimiter {
return {
allowed: true,
remaining: execLimit - actualNewRequests,
resetAt: new Date(new Date(rateLimitRecord.windowStart).getTime() + 60000),
resetAt: new Date(new Date(rateLimitRecord.windowStart).getTime() + RATE_LIMIT_WINDOW_MS),
}
} catch (error) {
logger.error('Error checking rate limit:', error)
@@ -162,7 +172,7 @@ export class RateLimiter {
return {
allowed: true,
remaining: 0,
resetAt: new Date(Date.now() + 60000),
resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS),
}
}
}
@@ -181,9 +191,9 @@ export class RateLimiter {
if (triggerType === 'manual') {
return {
used: 0,
limit: 999999,
remaining: 999999,
resetAt: new Date(Date.now() + 60000),
limit: MANUAL_EXECUTION_LIMIT,
remaining: MANUAL_EXECUTION_LIMIT,
resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS),
}
}
@@ -192,7 +202,7 @@ export class RateLimiter {
? limit.asyncApiExecutionsPerMinute
: limit.syncApiExecutionsPerMinute
const now = new Date()
const windowStart = new Date(now.getTime() - 60000)
const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS)
const [rateLimitRecord] = await db
.select()
@@ -205,7 +215,7 @@ export class RateLimiter {
used: 0,
limit: execLimit,
remaining: execLimit,
resetAt: new Date(now.getTime() + 60000),
resetAt: new Date(now.getTime() + RATE_LIMIT_WINDOW_MS),
}
}
@@ -214,7 +224,7 @@ export class RateLimiter {
used,
limit: execLimit,
remaining: Math.max(0, execLimit - used),
resetAt: new Date(new Date(rateLimitRecord.windowStart).getTime() + 60000),
resetAt: new Date(new Date(rateLimitRecord.windowStart).getTime() + RATE_LIMIT_WINDOW_MS),
}
} catch (error) {
logger.error('Error getting rate limit status:', error)
@@ -225,7 +235,7 @@ export class RateLimiter {
used: 0,
limit: execLimit,
remaining: execLimit,
resetAt: new Date(Date.now() + 60000),
resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS),
}
}
}

View File

@@ -1,4 +1,5 @@
import type { InferSelectModel } from 'drizzle-orm'
import { env } from '@/lib/env'
import type { userRateLimits } from '@/db/schema'
// Database types
@@ -16,22 +17,28 @@ export interface RateLimitConfig {
asyncApiExecutionsPerMinute: number
}
// Rate limit window duration in milliseconds
export const RATE_LIMIT_WINDOW_MS = Number.parseInt(env.RATE_LIMIT_WINDOW_MS) || 60000
// Manual execution bypass value (effectively unlimited)
export const MANUAL_EXECUTION_LIMIT = Number.parseInt(env.MANUAL_EXECUTION_LIMIT) || 999999
export const RATE_LIMITS: Record<SubscriptionPlan, RateLimitConfig> = {
free: {
syncApiExecutionsPerMinute: 10,
asyncApiExecutionsPerMinute: 50,
syncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 10,
asyncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 50,
},
pro: {
syncApiExecutionsPerMinute: 25,
asyncApiExecutionsPerMinute: 200,
syncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 25,
asyncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 200,
},
team: {
syncApiExecutionsPerMinute: 75,
asyncApiExecutionsPerMinute: 500,
syncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 75,
asyncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 500,
},
enterprise: {
syncApiExecutionsPerMinute: 150,
asyncApiExecutionsPerMinute: 1000,
syncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 150,
asyncApiExecutionsPerMinute: Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 1000,
},
}

View File

@@ -87,6 +87,18 @@ app:
NEXT_PUBLIC_DOCUMENTATION_URL: "" # Documentation URL (leave empty for none)
NEXT_PUBLIC_TERMS_URL: "" # Terms of service URL (leave empty for none)
NEXT_PUBLIC_PRIVACY_URL: "" # Privacy policy URL (leave empty for none)
# Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS: "60000" # Rate limit window in milliseconds (1 minute)
MANUAL_EXECUTION_LIMIT: "999999" # Manual execution limit (effectively unlimited)
RATE_LIMIT_FREE_SYNC: "10" # Free tier sync API executions per minute
RATE_LIMIT_FREE_ASYNC: "50" # Free tier async API executions per minute
RATE_LIMIT_PRO_SYNC: "25" # Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: "200" # Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: "75" # Team tier sync API executions per minute
RATE_LIMIT_TEAM_ASYNC: "500" # Team tier async API executions per minute
RATE_LIMIT_ENTERPRISE_SYNC: "150" # Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: "1000" # Enterprise tier async API executions per minute
# Service configuration
service: