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:
Matt Hess
2026-02-01 20:49:09 +00:00
parent 6cb5f55d4c
commit fe0e8af07d
3 changed files with 359 additions and 3 deletions
+129 -3
View File
@@ -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);
}
+219
View File
@@ -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;
}
}
}
+11
View File
@@ -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,