Hi @ricmoo,
I'm using WebSocketProvider server-side to listen to blockchain events and performing calls to smart contracts.
Sometimes the websocket pipe got broken and I need to reconnect it.
I use this code to detect ws close and reconnect but it would be nice to not have to rely on _websocket to do it:
let wsProvider;
init = async () => {
wsProvider = new ethers.providers.WebSocketProvider(wsHost);
wsProvider._websocket.on('close', async (code) => {
console.log('ws closed', code);
wsProvider._websocket.terminate();
await sleep(3000); // wait before reconnect
init();
});
wsProvider.on('block', doStuff);
};
I also noticed when the websocket is broken Promise call don't reject wich is not super intuitive.
This is a very large feature... When I first (begrudgingly) added WebSocketProvider mentioned this would be something I would eventually get to, but that it won't be high priority any time soon. :)
But I want to! :)
It is still on the backlog, and I'll use this issue to track it, but there are other things I need to work on first.
Keep in mind when you reconnect, you may have been disconnected for a long time, in which case you should find and trigger events that were missed; you may have also been down fo a short period of time, in which case you must dedup events you've already emitted. Also, earlier events should be emitted before later ones. Unless there was a re-org, exactly-once semantics should be adhered to. All subscriptions will need some custom logic, depending on the type of subscription to handle this.
Also ethers providers guarantee consistent read-after-events. So, if a block number X has been emitted, a call to getBlock(X) must return a block. In many cases, due to the distributed nature of the Blockchain, especially with a FallbackProvider, one backend may have seen a block before others, so calling getBlock might occur on a node before it has actually seen the block, so the call must stall and (with exponential back-off) poll for the block and resolve it when it comes in. Similarly, this is true for events which include the transactionHash; a call to getTransaction must succeed, stalling until the data becomes available.
Also keep special note of block, debug, poll and network events which need themselves some coordination and may recall some changes in their super class to handle properly...
Basically, it's a feature I really want too, but I know it's going to take considerable time to complete and properly test. I just wanted to give some background on the complexity.
I think this is probably the best solution:
const EXPECTED_PONG_BACK = 15000
const KEEP_ALIVE_CHECK_INTERVAL = 7500
export const startConnection = () => {
provider = new ethers.providers.WebSocketProvider(config.ETH_NODE_WSS)
let pingTimeout = null
let keepAliveInterval = null
provider._websocket.on('open', () => {
keepAliveInterval = setInterval(() => {
logger.debug('Checking if the connection is alive, sending a ping')
provider._websocket.ping()
// Use `WebSocket#terminate()`, which immediately destroys the connection,
// instead of `WebSocket#close()`, which waits for the close timer.
// Delay should be equal to the interval at which your server
// sends out pings plus a conservative assumption of the latency.
pingTimeout = setTimeout(() => {
provider._websocket.terminate()
}, EXPECTED_PONG_BACK)
}, KEEP_ALIVE_CHECK_INTERVAL)
// TODO: handle contract listeners setup + indexing
})
provider._websocket.on('close', () => {
logger.error('The websocket connection was closed')
clearInterval(keepAliveInterval)
clearTimeout(pingTimeout)
startConnection()
})
provider._websocket.on('pong', () => {
logger.debug('Received pong, so connection is alive, clearing the timeout')
clearInterval(pingTimeout)
})
}
This send a ping every 15 seconds, when it sends a ping, it expects a pong back within 7.5 seconds otherwise it closes the connection and calls the main startConnection function to start everything over.
Where it says // TODO: handle contract listeners setup + indexing that's where you should do any indexing or listening for contract events etc.
Fine tune these timing vars to taste, depending on who your Node provider is, this are the settings I use for QuikNode
const EXPECTED_PONG_BACK = 15000
const KEEP_ALIVE_CHECK_INTERVAL = 7500
To elaborate on @mikevercoelen's answer, I extracted the logic to a function
type KeepAliveParams = {
provider: ethers.providers.WebSocketProvider;
onDisconnect: (err: any) => void;
expectedPongBack?: number;
checkInterval?: number;
};
const keepAlive = ({
provider,
onDisconnect,
expectedPongBack = 15000,
checkInterval = 7500,
}: KeepAliveParams) => {
let pingTimeout: NodeJS.Timeout | null = null;
let keepAliveInterval: NodeJS.Timeout | null = null;
provider._websocket.on('open', () => {
keepAliveInterval = setInterval(() => {
provider._websocket.ping();
// Use `WebSocket#terminate()`, which immediately destroys the connection,
// instead of `WebSocket#close()`, which waits for the close timer.
// Delay should be equal to the interval at which your server
// sends out pings plus a conservative assumption of the latency.
pingTimeout = setTimeout(() => {
provider._websocket.terminate();
}, expectedPongBack);
}, checkInterval);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider._websocket.on('close', (err: any) => {
if (keepAliveInterval) clearInterval(keepAliveInterval);
if (pingTimeout) clearTimeout(pingTimeout);
onDisconnect(err);
});
provider._websocket.on('pong', () => {
if (pingTimeout) clearInterval(pingTimeout);
});
};
Then in my code, i get:
const startBot = () => {
const provider = new ethers.providers.WebSocketProvider(wsUrl);
keepAlive({
provider,
onDisconnect: (err) => {
startBot();
console.error('The ws connection was closed', JSON.stringify(err, null, 2));
},
});
};
We're two months in and the code mentioned before, has been running steadily on our node :) 0 downtime.
Really cool ! Thanks again for sharing :)
@mikevercoelen I'm using ethers 5.0.32 and the websocket provider doesn't have the 'on' method which really hampers implementing your solution ;). What version of ethers are you using?
There should definitely be an .on method. There is no version of WebSocketProvider that didn鈥檛 have it, since it inherits from JsonRpcProvider.
Ok well I'm not sure what's going on. Its definitely not there, I'm seeing an interface for provider._websocket that looks just like a regular websocket interface: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/onopen.
Is there typo in the code above? Perhaps instead of
provider._websocket.on('open', () => {})
I should be calling these directly on the provider? I tried this too but the provider doesn't recognize the 'open', 'close', and 'pong' event types. websocket-provider.ts from ethers only shows these event types: 'block', 'pending', 'filter', 'tx', 'debug', 'poll', 'willPoll', 'didPoll', 'error'.
Oh! Sorry, yes. In general you should use provider.on. The _websocket is a semi-private member and should not generally be touched , unless direct access too it is needed. But only ethers-supported events are supported by provider.on.
It depends on your environment what your ._webprovider is. Some platforms may use .addEventListener instead of .on, maybe?
If your goal is to enable automatic reconnect, this is not something that is simple to do in a safe way, so make sure you test it thoroughly. :)
We are actually using alchemy so was able to just use their web3 websocket provider and plugged it into our ethers ecosystem with ethers.provider.Web3Provider. they handle all the reconnects and even dropped calls very gracefully.
One question @ricmoo , @gwendall when trying to use the code snippet above I get that the websocket object doesn't have on method.
I am using the latest ethers 5.3 from the dapp
@rrecuero I ran into the same problem and I'm still not sure how that code above works :P
The solution of the @mikevercoelen didn't worked on me maybe because I'm using the browser version of WebSocket so for me the workaround was writing a custom class that reconnect's everytime the connection closes.
const ethers = require("ethers");
class ReconnectableEthers {
/**
* Constructs the class
*/
constructor() {
this.provider = undefined;
this.wallet = undefined;
this.account = undefined;
this.config = undefined;
this.KEEP_ALIVE_CHECK_INTERVAL = 1000;
this.keepAliveInterval = undefined;
this.pingTimeout = undefined;
}
/**
* Load assets.
* @param {Object} config Config object.
*/
load(config) {
this.config = config;
this.provider = new ethers.providers.WebSocketProvider(this.config["BSC_PROVIDER_ADDRESS"])
this.wallet = new ethers.Wallet(this.config["PRIVATE_KEY"]);
this.account = this.wallet.connect(this.provider);
this.defWsOpen = this.provider._websocket.onopen;
this.defWsClose = this.provider._websocket.onclose;
this.provider._websocket.onopen = (event) => this.onWsOpen(event);
this.provider._websocket.onclose = (event) => this.onWsClose(event);
}
/**
* Check class is loaded.
* @returns Bool
*/
isLoaded() {
if (!this.provider) return false;
return true;
}
/**
* Triggered when provider's websocket is open.
*/
onWsOpen(event) {
console.log("Connected to the WS!");
this.keepAliveInterval = setInterval(() => {
if (
this.provider._websocket.readyState === WebSocket.OPEN ||
this.provider._websocket.readyState === WebSocket.OPENING
) return;
this.provider._websocket.close();
}, this.KEEP_ALIVE_CHECK_INTERVAL)
if (this.defWsOpen) this.defWsOpen(event);
}
/**
* Triggered on websocket termination.
* Tries to reconnect again.
*/
onWsClose(event) {
console.log("WS connection lost! Reconnecting...");
clearInterval(this.keepAliveInterval)
this.load(this.config);
if (this.defWsClose) this.defWsClose(event);
}
}
module.exports = ReconnectableEthers;
@tarik0 i'm running WebSocket on browser too, that's exactly what I need, thank you
Most helpful comment
I think this is probably the best solution:
This send a ping every 15 seconds, when it sends a ping, it expects a pong back within 7.5 seconds otherwise it closes the connection and calls the main
startConnectionfunction to start everything over.Where it says
// TODO: handle contract listeners setup + indexingthat's where you should do any indexing or listening for contract events etc.Fine tune these timing vars to taste, depending on who your Node provider is, this are the settings I use for QuikNode