@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
text/typescript
/**
* 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
});
}