import { RateLimitTokenBucket, RateLimitError } from '@logosdx/utils'; import type { _InternalHttpMethods, RateLimitRule, RateLimitConfig, RequestSerializer, CacheAdapter, RequestKeyOptions } from '../types.ts'; import type { FetchPlugin, FetchEnginePublic, InternalReqOptions } from '../engine/types.ts'; import { ResiliencePolicy } from './base.ts'; import { endpointSerializer } from '../serializers/index.ts'; import { validateMatchRules } from './helpers.ts'; import { FetchError } from '../helpers/fetch-error.ts'; /** * Extended internal state for rate limit policy. * Includes rate limit-specific fields and token bucket management. */ export interface RateLimitPolicyState { /** Whether the policy is globally enabled */ enabled: boolean; /** Set of HTTP methods this policy applies to */ methods: Set; /** The serializer function for bucket key generation */ serializer: RequestSerializer; /** Memoized rule cache: method:path -> resolved rule or null */ rulesCache: Map | null>; /** Max calls per window */ maxCalls: number; /** Window duration in milliseconds */ windowMs: number; /** Whether to wait for token vs reject immediately */ waitForToken: boolean; /** Token buckets by key */ rateLimiters: Map; } /** * Default HTTP methods for rate limiting. * All methods are rate limited by default. */ const DEFAULT_RATELIMIT_METHODS: _InternalHttpMethods[] = [ 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS' ]; /** * Default max calls per window. */ const DEFAULT_MAX_CALLS = 100; /** * Default window duration in milliseconds (1 minute). */ const DEFAULT_WINDOW_MS = 60000; /** * Rate limit policy for controlling request rate. * * Uses token bucket algorithm to enforce rate limits. Each unique key * (generated by the serializer) gets its own bucket, allowing per-endpoint * or per-user rate limiting. * * Uses endpoint-scoped serialization by default (method + path), * meaning all requests to the same endpoint share a rate limit bucket * regardless of their parameters or payload. * * @template S - Instance state type * @template H - Headers type * @template P - Params type */ export class RateLimitPolicy< S = unknown, H = unknown, P = unknown > extends ResiliencePolicy, RateLimitRule, S, H, P> { /** * Extended state with rate limit-specific fields. */ protected state: RateLimitPolicyState | null = null; /** * Adapter for distributed rate limiting. */ #adapter: CacheAdapter | undefined; /** * Get the adapter (if configured). */ get adapter(): CacheAdapter | undefined { return this.#adapter; } /** * Get the default serializer for rate limiting. */ protected getDefaultSerializer(): RequestSerializer { return endpointSerializer as RequestSerializer; } /** * Get the default HTTP methods for rate limiting. */ protected getDefaultMethods(): _InternalHttpMethods[] { return DEFAULT_RATELIMIT_METHODS; } /** * Initialize the rate limit policy with configuration. */ init(config?: boolean | RateLimitConfig): void { if (!config) { this.state = null; this.config = null; this.#adapter = undefined; return; } if (config === true) { this.state = { enabled: true, methods: new Set(this.getDefaultMethods()), serializer: this.getDefaultSerializer(), rulesCache: new Map(), maxCalls: DEFAULT_MAX_CALLS, windowMs: DEFAULT_WINDOW_MS, waitForToken: true, rateLimiters: new Map() }; this.config = {} as RateLimitConfig; this.#adapter = undefined; return; } this.config = config; this.#adapter = (config as any).adapter; this.state = { enabled: config.enabled !== false, methods: new Set(config.methods ?? this.getDefaultMethods()), serializer: config.serializer ?? this.getDefaultSerializer(), rulesCache: new Map(), maxCalls: config.maxCalls ?? DEFAULT_MAX_CALLS, windowMs: config.windowMs ?? DEFAULT_WINDOW_MS, waitForToken: config.waitForToken ?? true, rateLimiters: new Map() }; if (config.rules) { validateMatchRules(config.rules); } } /** * Merge a matched rule with policy defaults. */ protected mergeRuleWithDefaults(rule: RateLimitRule | null): RateLimitRule { if (!this.state) { return { enabled: true, serializer: this.getDefaultSerializer(), maxCalls: DEFAULT_MAX_CALLS, windowMs: DEFAULT_WINDOW_MS, waitForToken: true }; } return { enabled: true, serializer: rule?.serializer ?? this.state.serializer, maxCalls: rule?.maxCalls ?? this.state.maxCalls, windowMs: rule?.windowMs ?? this.state.windowMs, waitForToken: rule?.waitForToken ?? this.state.waitForToken }; } /** * Resolve rate limit configuration for a request. */ resolveForRequest( method: string, path: string, ctx: RequestKeyOptions ): RateLimitRule | null { const skipCallback = this.config?.shouldRateLimit ? (c: RequestKeyOptions) => this.config!.shouldRateLimit!(c) === false : undefined; return this.resolve(method, path, ctx, skipCallback); } /** * Get or create a rate limiter for the given key. */ getRateLimiter(key: string, maxCalls: number, windowMs: number): RateLimitTokenBucket { if (!this.state) { throw new Error('Rate limiting not initialized'); } let bucket = this.state.rateLimiters.get(key); if (!bucket) { const refillIntervalMs = windowMs / maxCalls; bucket = new RateLimitTokenBucket({ capacity: maxCalls, refillIntervalMs }); this.state.rateLimiters.set(key, bucket); } return bucket; } /** * Get the onRateLimit callback from config. */ get onRateLimit(): RateLimitConfig['onRateLimit'] { return this.config?.onRateLimit; } } /** * Factory function that creates a rate limit plugin for FetchEngine. * * The plugin installs a `beforeRequest` hook at priority `-30` that * enforces token bucket rate limiting before requests proceed. * * @param config - Rate limit configuration * @returns FetchPlugin that can be installed via `engine.use()` or `plugins` config * * @example * const api = new FetchEngine({ * baseUrl: 'https://api.example.com', * plugins: [ * rateLimitPlugin({ maxCalls: 60, windowMs: 60000 }) * ] * }); */ export function rateLimitPlugin( config: boolean | RateLimitConfig ): FetchPlugin { const policy = new RateLimitPolicy(); policy.init(config); return { name: 'rate-limit', // Re-runs init() with the updated config, rebuilding the rule cache // and token buckets — a runtime reconfigure gets fresh budgets, // matching what a freshly constructed engine would resolve. reconfigure(value: boolean | RateLimitConfig | undefined): void { policy.init(value); }, install(engine: FetchEnginePublic): () => void { const cleanup = engine.hooks.add('beforeRequest', async (_url, opts, _ctx) => { const normalizedOpts = opts as InternalReqOptions; const { method, path, controller } = normalizedOpts; const ruleConfig = policy.resolveForRequest( method, path, normalizedOpts as unknown as RequestKeyOptions ); if (ruleConfig === null) return; const key = ruleConfig.serializer!(normalizedOpts as unknown as RequestKeyOptions); const bucket = policy.getRateLimiter(key, ruleConfig.maxCalls!, ruleConfig.windowMs!); const snapshot = bucket.snapshot; const waitTimeMs = bucket.getWaitTimeMs(1); const eventData = { ...normalizedOpts, key, currentTokens: snapshot.currentTokens, capacity: snapshot.capacity, waitTimeMs, nextAvailable: bucket.getNextAvailable(1), }; const throwAborted = (waitedMs: number): never => { engine.emit('ratelimit-abort', { ...eventData, waitTimeMs: waitedMs, }); const err = new FetchError('Request aborted while waiting for rate limit token'); err.aborted = true; err.method = method; err.path = path; err.status = 499; err.step = 'fetch'; err.timedOut = normalizedOpts.getTotalTimeoutFired?.() ?? false; throw err; }; if (waitTimeMs > 0) { if (!ruleConfig.waitForToken) { engine.emit('ratelimit-reject' as any, eventData as any); throw new RateLimitError( `Rate limit exceeded for ${key}. Try again in ${waitTimeMs}ms`, ruleConfig.maxCalls! ); } engine.emit('ratelimit-wait' as any, eventData as any); if (policy.onRateLimit) { await policy.onRateLimit( normalizedOpts as unknown as RequestKeyOptions, waitTimeMs ); } const waitStart = Date.now(); const acquired = await bucket.waitAndConsume(1, { abortController: controller, }); if (!acquired) { throwAborted(Date.now() - waitStart); } const postWaitSnapshot = bucket.snapshot; engine.emit('ratelimit-acquire' as any, { ...normalizedOpts, key, currentTokens: postWaitSnapshot.currentTokens, capacity: postWaitSnapshot.capacity, waitTimeMs: 0, nextAvailable: bucket.getNextAvailable(1), } as any); } else { // A dead request must not spend a live token if (controller.signal.aborted) { throwAborted(0); } bucket.consume(1); const postConsumeSnapshot = bucket.snapshot; engine.emit('ratelimit-acquire' as any, { ...normalizedOpts, key, currentTokens: postConsumeSnapshot.currentTokens, capacity: postConsumeSnapshot.capacity, waitTimeMs: 0, nextAvailable: bucket.getNextAvailable(1), } as any); } }, { priority: -20 }); return cleanup; } }; }