UNPKG

minimal-xec-wallet

Version:

A minimalist eCash (XEC) wallet npm library, for use in web apps. Supports eTokens.

508 lines (415 loc) 15.2 kB
/* Robust Chronik Router with intelligent failover, health monitoring, and connection pooling. Based on Bitcoin-ABC chronik-client patterns with enhanced reliability features. */ const { ChronikClient } = require('chronik-client') // Connection error types for intelligent error handling const ConnectionErrorType = { NETWORK_TIMEOUT: 'network_timeout', SERVER_UNAVAILABLE: 'server_unavailable', SERVER_INDEXING: 'server_indexing', PROTOCOL_ERROR: 'protocol_error', RATE_LIMITED: 'rate_limited', CONNECTION_REFUSED: 'connection_refused' } // Connection strategies available const RobustConnectionStrategy = { CLOSEST_FIRST: 'ClosestFirst', ROUND_ROBIN: 'RoundRobin', PRIORITY_BASED: 'PriorityBased', LOAD_BALANCED: 'LoadBalanced' } class EndpointHealthMonitor { constructor (config = {}) { this.healthCheckInterval = config.healthCheckInterval || 30000 // 30 seconds this.maxLatencyHistory = config.maxLatencyHistory || 10 this.healthCheckTimeout = config.healthCheckTimeout || 5000 // Health tracking data this.healthScores = new Map() this.latencyHistory = new Map() this.failureCount = new Map() this.lastHealthCheck = new Map() this.isMonitoring = false this.healthCheckTimer = null } async startMonitoring (endpoints) { if (this.isMonitoring) return this.isMonitoring = true // Initialize health data for all endpoints for (const endpoint of endpoints) { this.initializeEndpointHealth(endpoint.url) } // Start periodic health checks this.healthCheckTimer = setInterval(() => { this._performHealthChecks(endpoints) }, this.healthCheckInterval) // Perform initial health check await this._performHealthChecks(endpoints) } stopMonitoring () { this.isMonitoring = false if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer) this.healthCheckTimer = null } } initializeEndpointHealth (url) { this.healthScores.set(url, 100) // Start with perfect health this.latencyHistory.set(url, []) this.failureCount.set(url, 0) this.lastHealthCheck.set(url, 0) } async _performHealthChecks (endpoints) { const healthCheckPromises = endpoints.map(endpoint => this._checkSingleEndpoint(endpoint).catch(err => { console.warn(`Health check failed for ${endpoint.url}:`, err.message) return false }) ) await Promise.allSettled(healthCheckPromises) } async _checkSingleEndpoint (endpoint) { const startTime = Date.now() try { // Use chronik's blockchain info as a lightweight health check const chronik = new ChronikClient(endpoint.url) await Promise.race([ chronik.blockchainInfo(), new Promise((resolve, reject) => setTimeout(() => reject(new Error('Health check timeout')), this.healthCheckTimeout) ) ]) const latency = Date.now() - startTime this._recordSuccess(endpoint.url, latency) return true } catch (err) { this._recordFailure(endpoint.url) return false } } _recordSuccess (url, latency) { // Update latency history const history = this.latencyHistory.get(url) || [] history.push(latency) if (history.length > this.maxLatencyHistory) { history.shift() } this.latencyHistory.set(url, history) // Reset failure count on success this.failureCount.set(url, 0) // Improve health score const currentScore = this.healthScores.get(url) || 0 const newScore = Math.min(100, currentScore + 10) this.healthScores.set(url, newScore) this.lastHealthCheck.set(url, Date.now()) } _recordFailure (url) { const failures = (this.failureCount.get(url) || 0) + 1 this.failureCount.set(url, failures) // Reduce health score based on consecutive failures const currentScore = this.healthScores.get(url) || 100 const penalty = Math.min(30, failures * 10) const newScore = Math.max(0, currentScore - penalty) this.healthScores.set(url, newScore) this.lastHealthCheck.set(url, Date.now()) } getHealthScore (url) { return this.healthScores.get(url) || 0 } getAverageLatency (url) { const history = this.latencyHistory.get(url) || [] if (history.length === 0) return Infinity const sum = history.reduce((a, b) => a + b, 0) return sum / history.length } getHealthiestEndpoints (endpoints) { return endpoints .filter(endpoint => this.getHealthScore(endpoint.url) > 20) // Only consider reasonably healthy endpoints .sort((a, b) => { const scoreA = this.getHealthScore(a.url) const scoreB = this.getHealthScore(b.url) const latencyA = this.getAverageLatency(a.url) const latencyB = this.getAverageLatency(b.url) // Prioritize health score, then latency if (scoreA !== scoreB) { return scoreB - scoreA // Higher score is better } return latencyA - latencyB // Lower latency is better }) } isEndpointHealthy (url, minScore = 30) { const score = this.getHealthScore(url) const failures = this.failureCount.get(url) || 0 return score >= minScore && failures < 3 } } class ConnectionErrorHandler { constructor () { this.maxRetries = 3 this.baseRetryDelay = 1000 // 1 second this.maxRetryDelay = 30000 // 30 seconds } classifyError (error) { const errorMsg = error.message?.toLowerCase() || '' const errorCode = error.code?.toLowerCase() || '' // Network-level errors if (errorCode === 'econnrefused' || errorMsg.includes('connection refused')) { return ConnectionErrorType.CONNECTION_REFUSED } if (errorCode === 'etimedout' || errorMsg.includes('timeout')) { return ConnectionErrorType.NETWORK_TIMEOUT } // Server-level errors if (errorMsg.includes('indexing') || errorMsg.includes('error state')) { return ConnectionErrorType.SERVER_INDEXING } if (errorMsg.includes('rate limit') || errorMsg.includes('too many requests')) { return ConnectionErrorType.RATE_LIMITED } if (errorMsg.includes('server unavailable') || errorMsg.includes('503')) { return ConnectionErrorType.SERVER_UNAVAILABLE } // Default to protocol error for chronik-specific errors return ConnectionErrorType.PROTOCOL_ERROR } shouldRetry (errorType, attemptNumber) { if (attemptNumber >= this.maxRetries) return false switch (errorType) { case ConnectionErrorType.NETWORK_TIMEOUT: case ConnectionErrorType.CONNECTION_REFUSED: case ConnectionErrorType.SERVER_UNAVAILABLE: return true case ConnectionErrorType.SERVER_INDEXING: return attemptNumber < 2 // Only retry once for indexing servers case ConnectionErrorType.RATE_LIMITED: return true case ConnectionErrorType.PROTOCOL_ERROR: return false // Don't retry protocol errors default: return false } } getRetryDelay (attemptNumber, errorType) { const baseDelay = this.baseRetryDelay // Adjust base delay based on error type let multiplier = 1 switch (errorType) { case ConnectionErrorType.RATE_LIMITED: multiplier = 3 // Longer delay for rate limiting break case ConnectionErrorType.SERVER_INDEXING: multiplier = 5 // Much longer delay for indexing servers break case ConnectionErrorType.NETWORK_TIMEOUT: multiplier = 2 break default: multiplier = 1 } // Exponential backoff with jitter const exponentialDelay = baseDelay * multiplier * Math.pow(2, attemptNumber - 1) const jitter = Math.random() * 0.1 * exponentialDelay // 10% jitter const finalDelay = Math.min(exponentialDelay + jitter, this.maxRetryDelay) return Math.floor(finalDelay) } } class RobustChronikRouter { constructor (config = {}) { this.strategy = config.strategy || RobustConnectionStrategy.CLOSEST_FIRST this.endpoints = [] this.currentEndpointIndex = 0 this.healthMonitor = new EndpointHealthMonitor(config.healthMonitor || {}) this.errorHandler = new ConnectionErrorHandler() this.connectionPool = new Map() this.maxConnectionsPerEndpoint = config.maxConnectionsPerEndpoint || 3 this.connectionTimeout = config.connectionTimeout || 10000 this.isInitialized = false // Statistics this.stats = { requestCount: 0, failoverCount: 0, totalLatency: 0, errorsByType: new Map() } } async initialize (urls, strategyOptions = {}) { try { if (this.isInitialized) { await this.cleanup() } // Validate and prepare endpoints this.endpoints = this._prepareEndpoints(urls) // Initialize health monitoring await this.healthMonitor.startMonitoring(this.endpoints) // Try to establish initial connection using chosen strategy await this._initializeWithStrategy(strategyOptions) this.isInitialized = true console.log(`RobustChronikRouter initialized with ${this.endpoints.length} endpoints using ${this.strategy} strategy`) } catch (err) { throw new Error(`Router initialization failed: ${err.message}`) } } _prepareEndpoints (urls) { const urlArray = Array.isArray(urls) ? urls : [urls] if (urlArray.length === 0) { throw new Error('At least one endpoint URL is required') } return urlArray.map((url, index) => ({ url: this._normalizeUrl(url), priority: index, // Lower index = higher priority isActive: true, createdAt: Date.now() })) } _normalizeUrl (url) { if (!url || typeof url !== 'string') { throw new Error('URL must be a non-empty string') } // Remove trailing slash const cleanUrl = url.replace(/\/$/, '') // Validate URL format if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) { throw new Error(`Invalid URL format: ${url}`) } return cleanUrl } async _initializeWithStrategy (options) { switch (this.strategy) { case RobustConnectionStrategy.CLOSEST_FIRST: await this._initializeClosestFirst() break case RobustConnectionStrategy.PRIORITY_BASED: this._initializePriorityBased() break case RobustConnectionStrategy.ROUND_ROBIN: this._initializeRoundRobin() break default: this._initializePriorityBased() // Default fallback } } async _initializeClosestFirst () { // Wait for initial health checks to complete await new Promise(resolve => setTimeout(resolve, 1000)) // Sort endpoints by health and latency const healthyEndpoints = this.healthMonitor.getHealthiestEndpoints(this.endpoints) if (healthyEndpoints.length > 0) { this.currentEndpointIndex = this.endpoints.indexOf(healthyEndpoints[0]) } else { console.warn('No healthy endpoints found, using first endpoint') this.currentEndpointIndex = 0 } } _initializePriorityBased () { // Sort by priority (lower number = higher priority) this.endpoints.sort((a, b) => a.priority - b.priority) this.currentEndpointIndex = 0 } _initializeRoundRobin () { this.currentEndpointIndex = 0 } async executeWithFailover (operation, maxAttempts = null) { const attempts = maxAttempts || this.endpoints.length * this.errorHandler.maxRetries let lastError = null for (let attempt = 1; attempt <= attempts; attempt++) { const endpoint = this._getCurrentEndpoint() try { this.stats.requestCount++ const startTime = Date.now() const result = await this._executeWithTimeout(operation, endpoint) // Record success metrics this.stats.totalLatency += Date.now() - startTime return result } catch (err) { lastError = err const errorType = this.errorHandler.classifyError(err) // Update error statistics const errorCount = this.stats.errorsByType.get(errorType) || 0 this.stats.errorsByType.set(errorType, errorCount + 1) // Check if we should retry if (!this.errorHandler.shouldRetry(errorType, attempt) || attempt === attempts) { throw err } // Move to next endpoint await this._rotateToNextHealthyEndpoint() this.stats.failoverCount++ // Wait before retry const retryDelay = this.errorHandler.getRetryDelay(attempt, errorType) console.warn(`Endpoint ${endpoint.url} failed (${errorType}), retrying in ${retryDelay}ms...`) await new Promise(resolve => setTimeout(resolve, retryDelay)) } } throw lastError || new Error('All failover attempts exhausted') } async _executeWithTimeout (operation, endpoint) { const timeoutPromise = new Promise((resolve, reject) => setTimeout(() => reject(new Error('Operation timeout')), this.connectionTimeout) ) return Promise.race([ operation(endpoint), timeoutPromise ]) } _getCurrentEndpoint () { if (this.currentEndpointIndex >= this.endpoints.length) { this.currentEndpointIndex = 0 } return this.endpoints[this.currentEndpointIndex] } async _rotateToNextHealthyEndpoint () { const startIndex = this.currentEndpointIndex let attempts = 0 do { this.currentEndpointIndex = (this.currentEndpointIndex + 1) % this.endpoints.length attempts++ const endpoint = this.endpoints[this.currentEndpointIndex] // Check if this endpoint is healthy if (this.healthMonitor.isEndpointHealthy(endpoint.url)) { return } } while (this.currentEndpointIndex !== startIndex && attempts < this.endpoints.length) // If no healthy endpoints found, use current anyway (last resort) console.warn('No healthy endpoints available, continuing with current endpoint') } async getConnection () { if (!this.isInitialized) { throw new Error('Router not initialized. Call initialize() first.') } const endpoint = this._getCurrentEndpoint() // Try to reuse existing connection from pool const poolKey = endpoint.url if (this.connectionPool.has(poolKey)) { return this.connectionPool.get(poolKey) } // Create new connection const chronik = new ChronikClient(endpoint.url) this.connectionPool.set(poolKey, chronik) return chronik } getStats () { const avgLatency = this.stats.requestCount > 0 ? this.stats.totalLatency / this.stats.requestCount : 0 return { ...this.stats, averageLatency: Math.round(avgLatency), currentEndpoint: this._getCurrentEndpoint().url, healthyEndpoints: this.endpoints.filter(ep => this.healthMonitor.isEndpointHealthy(ep.url) ).length, totalEndpoints: this.endpoints.length } } async cleanup () { this.healthMonitor.stopMonitoring() this.connectionPool.clear() this.isInitialized = false } } module.exports = { RobustChronikRouter, EndpointHealthMonitor, ConnectionErrorHandler, RobustConnectionStrategy, ConnectionErrorType }