UNPKG

@btc-stamps/tx-builder

Version:

Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection

428 lines (382 loc) 11.2 kB
/** * ElectrumX Rate Limiter * Implements rate limiting, request throttling, and exponential backoff */ import { setIntervalCompat, type TimerId } from '../utils/timer-utils.ts'; export interface RateLimitConfig { maxRequestsPerSecond: number; maxRequestsPerMinute: number; maxConcurrentRequests: number; backoffMultiplier: number; maxBackoffDelay: number; baseBackoffDelay: number; resetWindowMs: number; } interface RequestInfo { timestamp: number; serverKey: string; method: string; duration?: number; success: boolean; } interface ServerLimits { requestsThisSecond: number; requestsThisMinute: number; concurrentRequests: number; lastSecondReset: number; lastMinuteReset: number; consecutiveFailures: number; backoffDelay: number; nextAllowedRequest: number; } /** * Advanced rate limiter for ElectrumX requests with per-server tracking */ export class ElectrumXRateLimiter { private config: Required<RateLimitConfig>; private serverLimits = new Map<string, ServerLimits>(); private requestHistory: RequestInfo[] = []; private cleanupTimer: TimerId | null = null; constructor(config?: Partial<RateLimitConfig>) { this.config = { maxRequestsPerSecond: 10, maxRequestsPerMinute: 300, maxConcurrentRequests: 5, backoffMultiplier: 2, maxBackoffDelay: 30000, // 30 seconds baseBackoffDelay: 1000, // 1 second resetWindowMs: 60000, // 1 minute ...config, }; this.startCleanup(); } /** * Check if request should be allowed and get delay if needed */ checkRateLimit( serverKey: string, _method: string, // Required by interface: method ): { allowed: boolean; delayMs?: number; reason?: string } { const limits = this.getOrCreateServerLimits(serverKey); const now = Date.now(); // Update counters if reset windows have passed this.updateCounters(limits, now); // Check if we're in backoff period if (now < limits.nextAllowedRequest) { return { allowed: false, delayMs: limits.nextAllowedRequest - now, reason: `Backoff delay active (${limits.consecutiveFailures} consecutive failures)`, }; } // Check concurrent requests limit if (limits.concurrentRequests >= this.config.maxConcurrentRequests) { return { allowed: false, delayMs: 100, // Short delay reason: `Too many concurrent requests (${limits.concurrentRequests}/${this.config.maxConcurrentRequests})`, }; } // Check per-second limit if (limits.requestsThisSecond >= this.config.maxRequestsPerSecond) { const delayUntilNextSecond = 1000 - (now - limits.lastSecondReset); return { allowed: false, delayMs: delayUntilNextSecond, reason: `Rate limit exceeded: ${limits.requestsThisSecond}/${this.config.maxRequestsPerSecond} per second`, }; } // Check per-minute limit if (limits.requestsThisMinute >= this.config.maxRequestsPerMinute) { const delayUntilNextMinute = 60000 - (now - limits.lastMinuteReset); return { allowed: false, delayMs: delayUntilNextMinute, reason: `Rate limit exceeded: ${limits.requestsThisMinute}/${this.config.maxRequestsPerMinute} per minute`, }; } // Request allowed - increment counters limits.requestsThisSecond++; limits.requestsThisMinute++; limits.concurrentRequests++; return { allowed: true }; } /** * Record request start */ recordRequestStart(serverKey: string, method: string): string { const requestId = `${Date.now()}-${Math.random()}`; this.requestHistory.push({ timestamp: Date.now(), serverKey, method, success: true, // Will be updated on completion }); return requestId; } /** * Record request completion */ recordRequestComplete( serverKey: string, method: string, success: boolean, duration: number, ): void { const limits = this.serverLimits.get(serverKey); if (limits) { limits.concurrentRequests = Math.max(0, limits.concurrentRequests - 1); if (success) { // Reset backoff on success limits.consecutiveFailures = 0; limits.backoffDelay = this.config.baseBackoffDelay; limits.nextAllowedRequest = 0; } else { // Increase backoff on failure limits.consecutiveFailures++; limits.backoffDelay = Math.min( limits.backoffDelay * this.config.backoffMultiplier, this.config.maxBackoffDelay, ); limits.nextAllowedRequest = Date.now() + limits.backoffDelay; } } // Update request history const recent = this.requestHistory.find( (req) => req.serverKey === serverKey && req.method === method && !req.duration && Date.now() - req.timestamp < 60000, // Within last minute ); if (recent) { recent.duration = duration; recent.success = success; } } /** * Execute request with rate limiting */ async executeWithRateLimit<T>( serverKey: string, method: string, fn: () => Promise<T>, ): Promise<T> { while (true) { const rateCheck = this.checkRateLimit(serverKey, method); if (!rateCheck.allowed) { if (rateCheck.delayMs && rateCheck.delayMs > 0) { await this.sleep(rateCheck.delayMs); continue; // Try again after delay } else { throw new Error(`Rate limit exceeded: ${rateCheck.reason}`); } } break; // Rate limit check passed } const startTime = Date.now(); this.recordRequestStart(serverKey, method); try { const result = await fn(); this.recordRequestComplete( serverKey, method, true, Date.now() - startTime, ); return result; } catch (error) { this.recordRequestComplete( serverKey, method, false, Date.now() - startTime, ); throw error; } } /** * Get or create server limits */ private getOrCreateServerLimits(serverKey: string): ServerLimits { if (!this.serverLimits.has(serverKey)) { const now = Date.now(); this.serverLimits.set(serverKey, { requestsThisSecond: 0, requestsThisMinute: 0, concurrentRequests: 0, lastSecondReset: now, lastMinuteReset: now, consecutiveFailures: 0, backoffDelay: this.config.baseBackoffDelay, nextAllowedRequest: 0, }); } return this.serverLimits.get(serverKey)!; } /** * Update rate limit counters */ private updateCounters(limits: ServerLimits, now: number): void { // Reset second counter if needed if (now - limits.lastSecondReset >= 1000) { limits.requestsThisSecond = 0; limits.lastSecondReset = now; } // Reset minute counter if needed if (now - limits.lastMinuteReset >= 60000) { limits.requestsThisMinute = 0; limits.lastMinuteReset = now; } } /** * Start cleanup timer */ private startCleanup(): void { this.cleanupTimer = setIntervalCompat(() => { this.cleanupHistory(); }, this.config.resetWindowMs); } /** * Clean up old request history */ private cleanupHistory(): void { const cutoff = Date.now() - this.config.resetWindowMs; this.requestHistory = this.requestHistory.filter((req) => req.timestamp > cutoff); } /** * Sleep for specified milliseconds */ private sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Get rate limiting statistics */ getStats(): { servers: Array<{ serverKey: string; requestsThisSecond: number; requestsThisMinute: number; concurrentRequests: number; consecutiveFailures: number; backoffDelay: number; nextAllowedRequest: number; }>; recentRequests: { total: number; successful: number; failed: number; averageDuration: number; byMethod: Record<string, number>; }; } { const servers = Array.from(this.serverLimits.entries()).map(( [serverKey, limits], ) => ({ serverKey, ...limits, })); const recentRequests = this.requestHistory.filter((req) => req.duration !== undefined); const successful = recentRequests.filter((req) => req.success).length; const failed = recentRequests.length - successful; const averageDuration = recentRequests.length > 0 ? recentRequests.reduce((sum, req) => sum + (req.duration || 0), 0) / recentRequests.length : 0; const byMethod: Record<string, number> = {}; for (const req of recentRequests) { byMethod[req.method] = (byMethod[req.method] || 0) + 1; } return { servers, recentRequests: { total: recentRequests.length, successful, failed, averageDuration, byMethod, }, }; } /** * Reset rate limits for a server (use carefully) */ resetServerLimits(serverKey: string): void { const limits = this.serverLimits.get(serverKey); if (limits) { const now = Date.now(); limits.requestsThisSecond = 0; limits.requestsThisMinute = 0; limits.concurrentRequests = 0; limits.lastSecondReset = now; limits.lastMinuteReset = now; limits.consecutiveFailures = 0; limits.backoffDelay = this.config.baseBackoffDelay; limits.nextAllowedRequest = 0; } } /** * Reset all rate limits (use carefully) */ resetAllLimits(): void { for (const serverKey of this.serverLimits.keys()) { this.resetServerLimits(serverKey); } this.requestHistory = []; } /** * Update configuration */ updateConfig(newConfig: Partial<RateLimitConfig>): void { this.config = { ...this.config, ...newConfig }; } /** * Shutdown rate limiter */ shutdown(): void { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } this.serverLimits.clear(); this.requestHistory = []; } } /** * Create rate limiter with sensible defaults */ export function createElectrumXRateLimiter( config?: Partial<RateLimitConfig>, ): ElectrumXRateLimiter { return new ElectrumXRateLimiter(config); } /** * Create conservative rate limiter (fewer requests, longer backoff) */ export function createConservativeRateLimiter(): ElectrumXRateLimiter { return new ElectrumXRateLimiter({ maxRequestsPerSecond: 5, maxRequestsPerMinute: 150, maxConcurrentRequests: 3, backoffMultiplier: 3, maxBackoffDelay: 60000, // 1 minute baseBackoffDelay: 2000, // 2 seconds }); } /** * Create aggressive rate limiter (more requests, shorter backoff) */ export function createAggressiveRateLimiter(): ElectrumXRateLimiter { return new ElectrumXRateLimiter({ maxRequestsPerSecond: 20, maxRequestsPerMinute: 600, maxConcurrentRequests: 10, backoffMultiplier: 1.5, maxBackoffDelay: 15000, // 15 seconds baseBackoffDelay: 500, // 500ms }); }