Blockchain development is rapidly paving its way to occupy every industry. With its revolutionary characteristics, the popularity of blockchain nodes has been on the rise exponentially. However, a standard scenario is handling errors when sending RPC requests to nodes.
In its paid plan, Zeeve provides a tailored load balancing to its customers. But, since not all clients can opt for that, the blog will help share some examples to handle errors with RPC requests.
What is the problem with sending RPC requests?
While sending RPC requests to a non-balanced blockchain node, be prepared that some might fail or get a timeout. Although it happens very rarely, it can have a profound impact on arbitrage bots. Hence, it is essential to either rectify the issue, use a load balancer, or handle it at the application level to make sure it hits a live node.
Duplicate requests and use promises
You can try to send the request numerous times by using two ether providers. It is counted as one of the ways to handle RPC request errors.
Ensure that you use a different RPC endpoint every time, after which we can use multiple JavaScript Promise methods to handle the promises.
Promise.all
We will require a wrapped method that receives the RPC request promise and catches any errors.
/**
* @param {*} promise An RPC request promise to be resolved
* @param {*} origin URL of the node
* @returns resolved promise
*/
async function wrapRPCPromise(promise, origin) {
try {
const data = await promise
return { result: data, origin }
} catch (error) {
console.error('Error running method')
return new Error('Ops, there was an issue')
}
For this, we can use Promise.all() to wait until all requests have finished and their corresponding promises are fulfilled or rejected. For example, the code depicts how we are forcing an error in the mainProvider after the first RPC request.
let mainProvider = new ethers.providers.JsonRpcProvider(DEDICATED_NODE_RPC)
const backupProvider = new ethers.providers.JsonRpcProvider(BACKUP_NODE_RPC)
let prom1, prom2, res1, res2
prom1 = wrapRPCPromise(
mainProvider.getBlockNumber(),
mainProvider.connection.url
)
prom2 = wrapRPCPromise(
backupProvider.getBlockNumber(),
backupProvider.connection.url
)
try {
res1 = await Promise.all([prom1, prom2])
} catch (err) {
console.error(err)
}
console.log('getBlockNumber responses: ', res1)
// force an error
mainProvider = new ethers.providers.JsonRpcProvider(
'https://bad-rpc-endpoint/12345'
)
prom1 = wrapRPCPromise(mainProvider.getFeeData(), mainProvider.connection.url)
prom2 = wrapRPCPromise(
backupProvider.getFeeData(),
backupProvider.connection.url
)
try {
res2 = await Promise.all([prom2, prom1])
} catch (err) {
console.error(err)
}
console.log('getFeeData responses:', res2)
In the above code, we are catching errors through the wrapRPCPromise method, where Promise.all function provides a valid response in the returned array from one of the providers.
Although it is a good approach, it has its drawbacks too. Since we are duplicating requests, we have to manually check all the providers to filter which one returned a valid response. Also, the function Promise.all will wait till all promises are fulfilled or rejected. Hence, getting a response from the nodes will take a lot of time.
Promise.Race
It is a solution some developers use as it continues as soon as one of the promises is fulfilled or gets rejected.
let mainProvider = new ethers.providers.JsonRpcProvider(DEDICATED_NODE_RPC)
const backupProvider = new ethers.providers.JsonRpcProvider(BACKUP_NODE_RPC)
let prom1, prom2, res1
prom1 = wrapRPCPromise(
mainProvider.getBlockNumber(),
mainProvider.connection.url
)
prom2 = wrapRPCPromise(
backupProvider.getBlockNumber(),
backupProvider.connection.url
)
try {
res1 = await Promise.race([prom1, prom2])
} catch (err) {
console.error(err)
}
console.log('getBlockNumber response: ', res1)
// force an error
mainProvider = new ethers.providers.JsonRpcProvider(
'https://bad-rpc-endpoint/12345'
)
prom1 = wrapRPCPromise(mainProvider.getFeeData(), mainProvider.connection.url)
prom2 = wrapRPCPromise(
backupProvider.getFeeData(),
backupProvider.connection.url
)
try {
res2 = await Promise.race([prom2, prom1])
} catch (err) {
console.error(err)
}
console.log('getFeeData responses:', res2)
Although it makes the solution a little fast, the downside is if one of the requests fails before the other one succeeds, the result we’ll get will be the error returned.
Promise.any
It is better than both the functions Promise.all and Promise.race. The function ignores the errors and returns a single promise that resolves as soon as any promises are fulfilled. Hence, you only need to change the wrapRPCPromise method to reject when there is an issue.
let mainProvider = new ethers.providers.JsonRpcProvider(DEDICATED_NODE_RPC)
const backupProvider = new ethers.providers.JsonRpcProvider(BACKUP_NODE_RPC)
let prom1, prom2, res1
prom1 = wrapRPCPromiseWithReject(
mainProvider.getBlockNumber(),
mainProvider.connection.url
)
prom2 = wrapRPCPromiseWithReject(
backupProvider.getBlockNumber(),
backupProvider.connection.url
)
try {
res1 = await Promise.any([prom1, prom2])
} catch (err) {
console.error(err)
}
console.log('getBlockNumber response: ', res1)
// force an error
mainProvider = new ethers.providers.JsonRpcProvider(
'https://bad-rpc-endpoint/12345'
)
prom1 = wrapRPCPromise(mainProvider.getFeeData(), mainProvider.connection.url)
prom2 = wrapRPCPromise(
backupProvider.getFeeData(),
backupProvider.connection.url
)
try {
res2 = await Promise.any([prom2, prom1])
} catch (err) {
console.error(err)
}
console.log('getFeeData responses:', res2)
Note: Since the function Promise.any was added in Node v15, ensure you’re running one of the newest versions to utilize this function.
Use a function wrapper
If you have no luck with the promise functions, you can retry the same RPC request whenever it fails, using the same endpoint and provider. First, create a different wrapper function that receives the RPC request promise and the number of retries.
Note: The function will return the response if the promise is fulfilled. Otherwise, it will reduce the counter of retries left and recursively call the same method.
/**
* @param promise An RPC request promise to be resolved
* @retriesLeft Number of tries before rejecting
* @returns resolved promise
*/
async function retryRPCPromise(promise, retriesLeft) {
try {
// try to resolve the promise
const data = await promise
// if resolved simply return the result
return data
} catch (error) {
// if no retries left, return error
if (retriesLeft === 0) {
return Promise.reject(error)
}
console.log(`${retriesLeft} retries left`)
// if there are retries left, reduce counter and
// call same function recursively
return retryPromise(promise, retriesLeft - 1)
}
}
Retry wrapper with delay
An iteration of the previous solution is to add a delay between each retry. You can use a wait() method that leverages the setTimeout() function and calls it before each retry.
/**
* @param ms miliseconds to wait
* @returns empty promise after delay
*/
function wait(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, ms)
})
}
/**
* @param promise An RPC method promise to be resolved
* @retriesLeft Number of tries before rejecting
* @returns resolved promise
*/
async function retryRPCPromiseWithDelay(promise, retriesLeft, delay) {
try {
// try to resolve the promise
const data = await promise
// if resolved simply return the result
return data
} catch (error) {
// if no retries left, return error
if (retriesLeft === 0) {
return Promise.reject(error)
}
// if there are retries left, reduce counter and
// call same function recursively
console.log(`${retriesLeft} retries left`)
// wait for delay
await wait(delay)
// following retries after 1000ms
return retryRPCPromiseWithDelay(promise, retriesLeft - 1, 1000)
}
}
Try to use a backup provider
Although the solutions detailed above are a good way to handle this, they all have their drawbacks. The best way is to send a single RPC request. If it fails, it sends the request to a different endpoint.
const { ethers } = require('ethers')
let mainProvider = new ethers.providers.JsonRpcProvider(DEDICATED_NODE_RPC)
const backupProvider = new ethers.providers.JsonRpcProvider(BACKUP_NODE_RPC)
const main = async () => {
try {
//
let res1, res2, res3
try {
res1 = await mainProvider.getBlockNumber()
} catch (error) {
console.error('Main provider failed')
res1 = await backupProvider.getBlockNumber()
}
console.log('getBlockNumber response: ', res1)
// force an error
mainProvider = new ethers.providers.JsonRpcProvider(
'https://bad-rpc-endpoint/12345'
)
try {
res2 = await mainProvider.getGasPrice()
} catch (error) {
console.error('Main provider failed')
res2 = await backupProvider.getGasPrice()
}
console.log('getGasPrice response: ', res2)
// fix provider
mainProvider = new ethers.providers.JsonRpcProvider(DEDICATED_NODE_RPC)
try {
res3 = await mainProvider.getNetwork()
} catch (error) {
console.error('Main provider failed')
res3 = await backupProvider.getNetwork()
}
console.log('getNetwork response: ', res3)
} catch (err) {
console.error(err)
}
}
main()
The backup provider helps to wrap each request in a try/catch. Even if it fails, the function will send the same request again via the backup provider using a different RPC endpoint.
Retry with a backup provider for smart contract methods
The above solution will work for general blockchain methods such as getBlockNumber or getGassPrice. However, if we want to target smart contract methods, we’d need a more generic wrapper.
// list of all available RPC endpoints
const allRPCs = [BAD_RPC, DEDICATED_NODE_RPC, BACKUP_NODE_RPC]
/**
*
* @param {*} contractAddress blockchain address of the smart contract
* @param {*} abi smart contract JSON ABI
* @param {*} rpc RPC endpoint
* @returns a contract instance to execute methods
*/
function initContractRef(contractAddress, abi, rpc) {
// init provider
const provider = new ethers.providers.JsonRpcProvider(rpc)
// init contract
const contract = new ethers.Contract(contractAddress, abi, provider)
return contract
}
/**
*
* @param {*} contractAddress blockchain address of the smart contract
* @param {*} abi smart contract JSON ABI
* @param {*} methodName name of the smart contract method to run
* @param {*} params parameters required for the smart contract method
* @param {*} tryNumber default to 0. Each retry adds one, which uses a different RPC endpoint
* @returns
*/
async function wrapContratMethodWithRetries(
contractAddress,
abi,
methodName,
params,
tryNumber = 0
) {
try {
let contract, data
console.log(`Running contract method via ${allRPCs[tryNumber]}`)
// initialise smart contract reference with a new rpc endpoint
contract = initContractRef(contractAddress, abi, allRPCs[tryNumber])
// execute smart contract method
data = await contract[methodName](...params)
return data
} catch (error) {
if (tryNumber > allRPCs.length - 1) {
return Promise.reject(error)
}
console.error('Error in contract method, retrying with different RPC')
return wrapContratMethodWithRetries(
contractAddress,
abi,
methodName,
params,
tryNumber + 1
)
}
}
The above method is efficient in handling retries using a different provider. Here is the step-by-step review of the above code.
- The wrapContratMethodWithRetries method will work for all the available RPC endpoints for each try.
- This method receives the following parameters: name of the contract, contract address, contract ABI, the parameters required, and the retry number, which defaults to 0.
- It creates a smart contract instance using the utility function initContractRef, which receives the ABI, the contract address, and the RPC endpoint, which is passed using the retry number as the index of the array.
- After that, it tries to execute the smart contract method. It returns the data if everything works perfectly fine.
- In the event of an error, the program checks for the tried RPC endpoints, and returns the error. However, if it can still try different endpoints, the program itself recursively with the same parameters, only increasing the retry counter.
const { wrapContratMethodWithRetries } = require('./wrappers')
// USDC smart contract address
const USDC_CONTRACT_ADDR = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
const USDC_ABI = require('./erc20.abi.json')
const main = async () => {
try {
const contractMethod = 'balanceOf'
// enter a valid wallet address here
const methodParams = ['0x1234567890123456789012345678901234567890']
let res
try {
res = await wrapContratMethodWithRetries(
USDC_CONTRACT_ADDR,
USDC_ABI,
contractMethod,
methodParams
)
} catch (error) {
console.error('Unable to run contract method via any RPC endpoints')
}
console.log('contract method response is: ', res.toNumber())
} catch (err) {
console.error('ERROR')
console.error(err)
}
}
main()
If we want to execute payable transactions, we could modify the wrapContratMethodWithRetries and initContractRef to use an ethers’ signer and receive the amount of ETH to send.
Final Thoughts
The above article displays the number of ways we can handle RPC request errors. Each solution has its pros and cons and depends on the type of error you want to rectify. For hassle-free work, Zeeve provides the full node balancer to handle all the errors. Get in touch with our team now!