d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
221 lines
6.6 KiB
TypeScript
221 lines
6.6 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
|
|
import { createStorageAdapter, type RateLimitStorageAdapter } from './storage'
|
|
import {
|
|
getRateLimit,
|
|
MANUAL_EXECUTION_LIMIT,
|
|
RATE_LIMIT_WINDOW_MS,
|
|
type RateLimitCounterType,
|
|
type SubscriptionPlan,
|
|
type TriggerType,
|
|
} from './types'
|
|
|
|
const logger = createLogger('RateLimiter')
|
|
|
|
interface SubscriptionInfo {
|
|
plan: string
|
|
referenceId: string
|
|
}
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean
|
|
remaining: number
|
|
resetAt: Date
|
|
retryAfterMs?: number
|
|
}
|
|
|
|
export interface RateLimitStatus {
|
|
requestsPerMinute: number
|
|
maxBurst: number
|
|
remaining: number
|
|
resetAt: Date
|
|
}
|
|
|
|
export class RateLimiter {
|
|
private storage: RateLimitStorageAdapter
|
|
|
|
constructor(storage?: RateLimitStorageAdapter) {
|
|
this.storage = storage ?? createStorageAdapter()
|
|
}
|
|
|
|
private getRateLimitKey(userId: string, subscription: SubscriptionInfo | null): string {
|
|
if (!subscription) return userId
|
|
|
|
if (isOrgScopedSubscription(subscription, userId)) {
|
|
return subscription.referenceId
|
|
}
|
|
|
|
return userId
|
|
}
|
|
|
|
private getCounterType(triggerType: TriggerType, isAsync: boolean): RateLimitCounterType {
|
|
if (triggerType === 'api-endpoint') return 'api-endpoint'
|
|
return isAsync ? 'async' : 'sync'
|
|
}
|
|
|
|
private buildStorageKey(rateLimitKey: string, counterType: RateLimitCounterType): string {
|
|
return `${rateLimitKey}:${counterType}`
|
|
}
|
|
|
|
private createUnlimitedResult(): RateLimitResult {
|
|
return {
|
|
allowed: true,
|
|
remaining: MANUAL_EXECUTION_LIMIT,
|
|
resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS),
|
|
}
|
|
}
|
|
|
|
async checkRateLimitWithSubscription(
|
|
userId: string,
|
|
subscription: SubscriptionInfo | null,
|
|
triggerType: TriggerType = 'manual',
|
|
isAsync = false
|
|
): Promise<RateLimitResult> {
|
|
try {
|
|
if (triggerType === 'manual') {
|
|
return this.createUnlimitedResult()
|
|
}
|
|
|
|
const plan = (subscription?.plan || 'free') as SubscriptionPlan
|
|
const rateLimitKey = this.getRateLimitKey(userId, subscription)
|
|
const counterType = this.getCounterType(triggerType, isAsync)
|
|
const config = getRateLimit(plan, counterType)
|
|
const storageKey = this.buildStorageKey(rateLimitKey, counterType)
|
|
|
|
const result = await this.storage.consumeTokens(storageKey, 1, config)
|
|
|
|
if (!result.allowed) {
|
|
logger.info('Rate limit exceeded', {
|
|
rateLimitKey,
|
|
counterType,
|
|
plan,
|
|
tokensRemaining: result.tokensRemaining,
|
|
})
|
|
}
|
|
|
|
return {
|
|
allowed: result.allowed,
|
|
remaining: result.tokensRemaining,
|
|
resetAt: result.resetAt,
|
|
retryAfterMs: result.retryAfterMs,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Rate limit storage error - failing open (allowing request)', {
|
|
error: toError(error).message,
|
|
userId,
|
|
triggerType,
|
|
isAsync,
|
|
})
|
|
return {
|
|
allowed: true,
|
|
remaining: 1,
|
|
resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS),
|
|
}
|
|
}
|
|
}
|
|
|
|
async getRateLimitStatusWithSubscription(
|
|
userId: string,
|
|
subscription: SubscriptionInfo | null,
|
|
triggerType: TriggerType = 'manual',
|
|
isAsync = false
|
|
): Promise<RateLimitStatus> {
|
|
try {
|
|
const plan = (subscription?.plan || 'free') as SubscriptionPlan
|
|
const counterType = this.getCounterType(triggerType, isAsync)
|
|
const config = getRateLimit(plan, counterType)
|
|
|
|
if (triggerType === 'manual') {
|
|
return {
|
|
requestsPerMinute: MANUAL_EXECUTION_LIMIT,
|
|
maxBurst: MANUAL_EXECUTION_LIMIT,
|
|
remaining: MANUAL_EXECUTION_LIMIT,
|
|
resetAt: new Date(Date.now() + config.refillIntervalMs),
|
|
}
|
|
}
|
|
|
|
const rateLimitKey = this.getRateLimitKey(userId, subscription)
|
|
const storageKey = this.buildStorageKey(rateLimitKey, counterType)
|
|
|
|
const status = await this.storage.getTokenStatus(storageKey, config)
|
|
|
|
return {
|
|
requestsPerMinute: config.refillRate,
|
|
maxBurst: config.maxTokens,
|
|
remaining: Math.floor(status.tokensAvailable),
|
|
resetAt: status.nextRefillAt,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error getting rate limit status - returning default config', {
|
|
error: toError(error).message,
|
|
userId,
|
|
triggerType,
|
|
isAsync,
|
|
})
|
|
const plan = (subscription?.plan || 'free') as SubscriptionPlan
|
|
const counterType = this.getCounterType(triggerType, isAsync)
|
|
const config = getRateLimit(plan, counterType)
|
|
return {
|
|
requestsPerMinute: config.refillRate,
|
|
maxBurst: config.maxTokens,
|
|
remaining: 0,
|
|
resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS),
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Consume one token from a bucket. On storage failure the default is to fail
|
|
* open (allow the request) so a limiter outage never takes down normal traffic.
|
|
* Pass `failClosed: true` for security-critical checks — e.g. a captcha
|
|
* backstop — where an unenforceable limit must reject rather than admit.
|
|
*/
|
|
async checkRateLimitDirect(
|
|
storageKey: string,
|
|
config: { maxTokens: number; refillRate: number; refillIntervalMs: number },
|
|
options?: { failClosed?: boolean }
|
|
): Promise<RateLimitResult> {
|
|
try {
|
|
const result = await this.storage.consumeTokens(storageKey, 1, config)
|
|
if (!result.allowed) {
|
|
logger.info('Rate limit exceeded', { storageKey, tokensRemaining: result.tokensRemaining })
|
|
}
|
|
return {
|
|
allowed: result.allowed,
|
|
remaining: result.tokensRemaining,
|
|
resetAt: result.resetAt,
|
|
retryAfterMs: result.retryAfterMs,
|
|
}
|
|
} catch (error) {
|
|
const failClosed = options?.failClosed === true
|
|
logger.error(
|
|
`Rate limit storage error - failing ${failClosed ? 'closed (rejecting request)' : 'open (allowing request)'}`,
|
|
{
|
|
error: toError(error).message,
|
|
storageKey,
|
|
}
|
|
)
|
|
return {
|
|
allowed: !failClosed,
|
|
remaining: failClosed ? 0 : 1,
|
|
resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS),
|
|
}
|
|
}
|
|
}
|
|
|
|
async resetRateLimit(rateLimitKey: string): Promise<void> {
|
|
try {
|
|
await Promise.all([
|
|
this.storage.resetBucket(`${rateLimitKey}:sync`),
|
|
this.storage.resetBucket(`${rateLimitKey}:async`),
|
|
this.storage.resetBucket(`${rateLimitKey}:api-endpoint`),
|
|
])
|
|
logger.info(`Reset rate limit for ${rateLimitKey}`)
|
|
} catch (error) {
|
|
logger.error('Error resetting rate limit:', error)
|
|
throw error
|
|
}
|
|
}
|
|
}
|