Add connection manager with latency-based server selection and failover
- Race multiple RPC servers on connect, pick fastest responder - Automatic failover to next-fastest on network errors - Re-race when latency degrades beyond 2x baseline - Ranked failover order maintained after each race - Hardcoded Salvium seed nodes (seed01-03.salvium.io) per network - Supports single URL (no overhead), multiple URLs, or network name - Comma-separated DAEMON_URL env var for multi-server configs - Backward compatible: existing single-URL usage unchanged
This commit is contained in:
+129
-3
@@ -4,8 +4,14 @@
|
||||
* Provides HTTP client infrastructure for Salvium daemon and wallet RPC.
|
||||
* Supports JSON-RPC 2.0 protocol with authentication, retries, and error handling.
|
||||
* Works in both browser and Node.js environments.
|
||||
*
|
||||
* Supports multiple server URLs with latency-based selection via ConnectionManager.
|
||||
* Single URL = direct connection (no overhead).
|
||||
* Multiple URLs = race on connect, pick fastest, failover on error.
|
||||
*/
|
||||
|
||||
import { ConnectionManager, SEED_NODES } from './connection-manager.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} RPCClientOptions
|
||||
* @property {string} url - RPC server URL (e.g., 'http://localhost:19081')
|
||||
@@ -67,11 +73,28 @@ export class RPCClient {
|
||||
* @param {RPCClientOptions} options - Client configuration
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
if (!options.url) {
|
||||
throw new Error('RPC client requires a URL');
|
||||
// Resolve URLs: explicit urls array, network seed nodes, or single url
|
||||
let urls = options.urls || null;
|
||||
if (!urls && options.network && SEED_NODES[options.network]) {
|
||||
urls = SEED_NODES[options.network];
|
||||
}
|
||||
|
||||
if (urls && urls.length > 0) {
|
||||
this.url = urls[0].replace(/\/+$/, '');
|
||||
this._connectionManager = new ConnectionManager({
|
||||
urls: urls.map(u => u.replace(/\/+$/, '')),
|
||||
raceTimeout: options.raceTimeout || 5000,
|
||||
degradationFactor: options.degradationFactor || 2,
|
||||
raceInterval: options.raceInterval || 0,
|
||||
onSwitch: options.onSwitch || null,
|
||||
});
|
||||
} else if (options.url) {
|
||||
this.url = options.url.replace(/\/+$/, '');
|
||||
this._connectionManager = null;
|
||||
} else {
|
||||
throw new Error('RPC client requires a url, urls array, or network name');
|
||||
}
|
||||
|
||||
this.url = options.url.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
this.username = options.username || null;
|
||||
this.password = options.password || null;
|
||||
this.timeout = options.timeout || 30000;
|
||||
@@ -81,6 +104,31 @@ export class RPCClient {
|
||||
this._requestId = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active URL (may change with ConnectionManager)
|
||||
* @returns {string}
|
||||
*/
|
||||
getActiveUrl() {
|
||||
if (this._connectionManager) {
|
||||
return this._connectionManager.activeUrl;
|
||||
}
|
||||
return this.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Race all configured servers and pick the fastest.
|
||||
* No-op if only one server is configured.
|
||||
* @returns {Promise<string>} The active URL after racing
|
||||
*/
|
||||
async race() {
|
||||
if (this._connectionManager) {
|
||||
const url = await this._connectionManager.race();
|
||||
this.url = url;
|
||||
return url;
|
||||
}
|
||||
return this.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique request ID
|
||||
* @returns {number} Request ID
|
||||
@@ -148,6 +196,47 @@ export class RPCClient {
|
||||
* @param {Object} [params={}] - Method parameters
|
||||
* @returns {Promise<RPCResponse>}
|
||||
*/
|
||||
/**
|
||||
* Get the current URL, ensuring connection manager has raced if needed.
|
||||
* @returns {Promise<string>}
|
||||
* @private
|
||||
*/
|
||||
async _resolveUrl() {
|
||||
if (this._connectionManager) {
|
||||
await this._connectionManager.ensureConnected();
|
||||
this.url = this._connectionManager.activeUrl;
|
||||
}
|
||||
return this.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record latency and handle failover via connection manager.
|
||||
* @param {number} latencyMs - Response latency
|
||||
* @private
|
||||
*/
|
||||
_recordLatency(latencyMs) {
|
||||
if (this._connectionManager) {
|
||||
this._connectionManager.recordLatency(latencyMs);
|
||||
this.url = this._connectionManager.activeUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle network failure — failover to next server if available.
|
||||
* @returns {Promise<boolean>} true if a new server is available to retry
|
||||
* @private
|
||||
*/
|
||||
async _handleNetworkFailure() {
|
||||
if (this._connectionManager && this._connectionManager.isMultiServer) {
|
||||
const newUrl = await this._connectionManager.handleFailure();
|
||||
if (newUrl) {
|
||||
this.url = newUrl;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async call(method, params = {}) {
|
||||
const payload = {
|
||||
jsonrpc: '2.0',
|
||||
@@ -156,10 +245,13 @@ export class RPCClient {
|
||||
params
|
||||
};
|
||||
|
||||
await this._resolveUrl();
|
||||
|
||||
let lastError = null;
|
||||
const attempts = this.retries + 1;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await this._fetchWithTimeout(`${this.url}/json_rpc`, {
|
||||
method: 'POST',
|
||||
@@ -167,6 +259,8 @@ export class RPCClient {
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
this._recordLatency(Date.now() - start);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
@@ -225,6 +319,11 @@ export class RPCClient {
|
||||
};
|
||||
}
|
||||
|
||||
// Try failover to another server before exhausting retries
|
||||
if (await this._handleNetworkFailure()) {
|
||||
continue; // Retry on the new server
|
||||
}
|
||||
|
||||
if (attempt < attempts) {
|
||||
await this._sleep(this.retryDelay);
|
||||
}
|
||||
@@ -244,10 +343,13 @@ export class RPCClient {
|
||||
* @returns {Promise<RPCResponse>}
|
||||
*/
|
||||
async post(endpoint, data = {}) {
|
||||
await this._resolveUrl();
|
||||
|
||||
let lastError = null;
|
||||
const attempts = this.retries + 1;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const url = endpoint.startsWith('/')
|
||||
? `${this.url}${endpoint}`
|
||||
@@ -259,6 +361,8 @@ export class RPCClient {
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
this._recordLatency(Date.now() - start);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
@@ -301,6 +405,10 @@ export class RPCClient {
|
||||
};
|
||||
}
|
||||
|
||||
if (await this._handleNetworkFailure()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attempt < attempts) {
|
||||
await this._sleep(this.retryDelay);
|
||||
}
|
||||
@@ -320,10 +428,13 @@ export class RPCClient {
|
||||
* @returns {Promise<RPCResponse>}
|
||||
*/
|
||||
async postBinary(endpoint, body) {
|
||||
await this._resolveUrl();
|
||||
|
||||
let lastError = null;
|
||||
const attempts = this.retries + 1;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const url = endpoint.startsWith('/')
|
||||
? `${this.url}${endpoint}`
|
||||
@@ -338,6 +449,8 @@ export class RPCClient {
|
||||
body
|
||||
});
|
||||
|
||||
this._recordLatency(Date.now() - start);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
@@ -370,6 +483,10 @@ export class RPCClient {
|
||||
};
|
||||
}
|
||||
|
||||
if (await this._handleNetworkFailure()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attempt < attempts) {
|
||||
await this._sleep(this.retryDelay);
|
||||
}
|
||||
@@ -389,10 +506,13 @@ export class RPCClient {
|
||||
* @returns {Promise<RPCResponse>}
|
||||
*/
|
||||
async get(endpoint, params = {}) {
|
||||
await this._resolveUrl();
|
||||
|
||||
let lastError = null;
|
||||
const attempts = this.retries + 1;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
let url = endpoint.startsWith('/')
|
||||
? `${this.url}${endpoint}`
|
||||
@@ -415,6 +535,8 @@ export class RPCClient {
|
||||
headers: this._buildHeaders()
|
||||
});
|
||||
|
||||
this._recordLatency(Date.now() - start);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
@@ -448,6 +570,10 @@ export class RPCClient {
|
||||
};
|
||||
}
|
||||
|
||||
if (await this._handleNetworkFailure()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attempt < attempts) {
|
||||
await this._sleep(this.retryDelay);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Connection Manager — Latency-Based Server Selection
|
||||
*
|
||||
* Manages multiple RPC server URLs. Races them on connect, picks the fastest,
|
||||
* monitors latency, and re-races when performance degrades or a server fails.
|
||||
*
|
||||
* Single URL = no overhead, just uses it directly.
|
||||
* Multiple URLs = race, pick fastest, failover on error, re-race on degradation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default seed nodes for each Salvium network.
|
||||
* Same hostnames, different ports per network.
|
||||
*/
|
||||
export const SEED_NODES = {
|
||||
mainnet: [
|
||||
'http://seed01.salvium.io:19081',
|
||||
'http://seed02.salvium.io:19081',
|
||||
'http://seed03.salvium.io:19081',
|
||||
],
|
||||
testnet: [
|
||||
'http://seed01.salvium.io:29081',
|
||||
'http://seed02.salvium.io:29081',
|
||||
'http://seed03.salvium.io:29081',
|
||||
],
|
||||
stagenet: [
|
||||
'http://seed01.salvium.io:39081',
|
||||
'http://seed02.salvium.io:39081',
|
||||
'http://seed03.salvium.io:39081',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} ConnectionManagerOptions
|
||||
* @property {string[]} urls - Server URLs to race
|
||||
* @property {number} [raceTimeout=5000] - Timeout per server during race (ms)
|
||||
* @property {number} [degradationFactor=2] - Re-race when latency > baseline * factor
|
||||
* @property {number} [raceInterval=0] - Periodic re-race interval (ms), 0 = disabled
|
||||
* @property {function} [onSwitch] - Callback when active server changes (oldUrl, newUrl)
|
||||
*/
|
||||
|
||||
export class ConnectionManager {
|
||||
/**
|
||||
* @param {ConnectionManagerOptions} options
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.urls = options.urls || [];
|
||||
this.raceTimeout = options.raceTimeout || 5000;
|
||||
this.degradationFactor = options.degradationFactor || 2;
|
||||
this.raceInterval = options.raceInterval || 0;
|
||||
this.onSwitch = options.onSwitch || null;
|
||||
|
||||
this.activeUrl = this.urls[0] || null;
|
||||
this.baselineLatency = null;
|
||||
this._latencies = new Map(); // url -> last latency ms
|
||||
this._ranked = [...this.urls]; // sorted by latency after each race
|
||||
this._racing = false;
|
||||
this._raceTimer = null;
|
||||
this._initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this manager has multiple servers to race.
|
||||
*/
|
||||
get isMultiServer() {
|
||||
return this.urls.length > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Race all servers, pick the fastest responding one.
|
||||
* Sets activeUrl and baselineLatency.
|
||||
* @returns {Promise<string>} The winning URL
|
||||
*/
|
||||
async race() {
|
||||
if (this._racing) return this.activeUrl;
|
||||
if (this.urls.length === 0) return this.activeUrl;
|
||||
if (this.urls.length === 1) {
|
||||
this.activeUrl = this.urls[0];
|
||||
this._initialized = true;
|
||||
return this.activeUrl;
|
||||
}
|
||||
|
||||
this._racing = true;
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
this.urls.map(async (url) => {
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.raceTimeout);
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${url.replace(/\/+$/, '')}/get_info`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
await resp.json(); // consume body
|
||||
|
||||
const latency = Date.now() - start;
|
||||
this._latencies.set(url, latency);
|
||||
return { url, latency };
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
this._latencies.set(url, Infinity);
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Pick the fastest successful result
|
||||
let best = null;
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
if (!best || result.value.latency < best.latency) {
|
||||
best = result.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort URLs by latency (fastest first) for failover ordering
|
||||
this._ranked = [...this.urls].sort((a, b) => {
|
||||
const la = this._latencies.get(a) ?? Infinity;
|
||||
const lb = this._latencies.get(b) ?? Infinity;
|
||||
return la - lb;
|
||||
});
|
||||
|
||||
if (best) {
|
||||
const oldUrl = this.activeUrl;
|
||||
this.activeUrl = best.url;
|
||||
this.baselineLatency = best.latency;
|
||||
this._initialized = true;
|
||||
|
||||
if (oldUrl && oldUrl !== best.url && this.onSwitch) {
|
||||
this.onSwitch(oldUrl, best.url);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up periodic re-race
|
||||
if (this.raceInterval > 0 && !this._raceTimer) {
|
||||
this._raceTimer = setInterval(() => this.race(), this.raceInterval);
|
||||
// Don't keep process alive just for re-racing
|
||||
if (this._raceTimer.unref) this._raceTimer.unref();
|
||||
}
|
||||
} finally {
|
||||
this._racing = false;
|
||||
}
|
||||
|
||||
return this.activeUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure connection manager is initialized (first race completed).
|
||||
* @returns {Promise<string>} Active URL
|
||||
*/
|
||||
async ensureConnected() {
|
||||
if (!this._initialized) {
|
||||
return this.race();
|
||||
}
|
||||
return this.activeUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful response latency. Triggers re-race if degraded.
|
||||
* @param {number} latencyMs - Response time in milliseconds
|
||||
*/
|
||||
recordLatency(latencyMs) {
|
||||
if (!this.isMultiServer || !this.baselineLatency) return;
|
||||
|
||||
this._latencies.set(this.activeUrl, latencyMs);
|
||||
|
||||
// Check for degradation
|
||||
if (latencyMs > this.baselineLatency * this.degradationFactor) {
|
||||
// Fire re-race in background (don't await)
|
||||
this.race();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failure on the active server. Switch to next best, or re-race.
|
||||
* @returns {Promise<string|null>} New active URL, or null if all failed
|
||||
*/
|
||||
async handleFailure() {
|
||||
if (!this.isMultiServer) return this.activeUrl;
|
||||
|
||||
// Mark current as failed
|
||||
this._latencies.set(this.activeUrl, Infinity);
|
||||
|
||||
// Walk the ranked list (sorted by latency at last race) for next best
|
||||
const ranked = this._ranked || this.urls;
|
||||
for (const url of ranked) {
|
||||
if (url === this.activeUrl) continue;
|
||||
const lat = this._latencies.get(url) ?? 0;
|
||||
if (lat < Infinity) {
|
||||
const oldUrl = this.activeUrl;
|
||||
this.activeUrl = url;
|
||||
if (this.onSwitch) this.onSwitch(oldUrl, url);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// All servers have failed — re-race
|
||||
return this.race();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic re-racing and clean up.
|
||||
*/
|
||||
destroy() {
|
||||
if (this._raceTimer) {
|
||||
clearInterval(this._raceTimer);
|
||||
this._raceTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,12 @@ export {
|
||||
STAGENET_URL as WALLET_STAGENET_URL
|
||||
} from './wallet.js';
|
||||
|
||||
// Connection manager
|
||||
export {
|
||||
ConnectionManager,
|
||||
SEED_NODES
|
||||
} from './connection-manager.js';
|
||||
|
||||
// Default export with all components
|
||||
import { RPCClient, createClient, RPC_ERROR_CODES, RPC_STATUS } from './client.js';
|
||||
import {
|
||||
@@ -74,6 +80,7 @@ import {
|
||||
RESTRICTED_MAINNET_URL as DAEMON_RESTRICTED_MAINNET, RESTRICTED_TESTNET_URL as DAEMON_RESTRICTED_TESTNET, RESTRICTED_STAGENET_URL as DAEMON_RESTRICTED_STAGENET
|
||||
} from './daemon.js';
|
||||
import { WalletRPC, createWalletRPC, PRIORITY, TRANSFER_TYPE, MAINNET_URL as WALLET_MAINNET, TESTNET_URL as WALLET_TESTNET, STAGENET_URL as WALLET_STAGENET } from './wallet.js';
|
||||
import { ConnectionManager, SEED_NODES } from './connection-manager.js';
|
||||
|
||||
export default {
|
||||
// Base client
|
||||
@@ -82,6 +89,10 @@ export default {
|
||||
RPC_ERROR_CODES,
|
||||
RPC_STATUS,
|
||||
|
||||
// Connection manager
|
||||
ConnectionManager,
|
||||
SEED_NODES,
|
||||
|
||||
// Daemon RPC
|
||||
DaemonRPC,
|
||||
createDaemonRPC,
|
||||
|
||||
Reference in New Issue
Block a user