When the RPC provider fails (e.g., network error, 403 Forbidden, invalid API key), an unhandled promise rejection is thrown even when the error is properly caught in user code with try/catch.
Root Cause
drainCallQueue() is an async function called from setTimeout callbacks without awaiting or catching the returned promise:
Lines 66-68 (drainInterval setter):
this.#drainTimer = setTimeout(() => {
this.drainCallQueue() // async function - returned promise is ignored
}, this.#drainInterval);
Lines 80-82 (queueCall):
this.#drainTimer = setTimeout(() => {
this.drainCallQueue(); // async function - returned promise is ignored
}, this.#drainInterval);
When this.subprovider.call() fails inside drainCallQueue(), two issues occur:
- The rejection from
drainCallQueue() is unhandled (setTimeout callback doesn't catch it)
- The queued promises from
queueCall() are never rejected because the error occurs before the resolve/reject logic runs
Reproduction
import { Contract, JsonRpcProvider } from 'ethers'
import { MulticallProvider } from '@ethers-ext/provider-multicall'
const provider = new JsonRpcProvider('https://invalid-rpc-url.example.com')
const multicallProvider = new MulticallProvider(provider)
const contract = new Contract('0x6B175474E89094C44Da98b954EedeAC495271d0F',
['function balanceOf(address) view returns (uint256)'],
multicallProvider)
try {
await contract.balanceOf('0x0000000000000000000000000000000000000001')
} catch (error) {
console.log('Caught:', error.message) // Error IS caught here
}
// But an unhandled rejection is ALSO printed to console
Proposed Fix
Wrap the subprovider.call() in try/catch inside the runner and properly reject all queued promises on error:
runners.push((async () => {
try {
const _data = await this.subprovider.call({ data, blockTag });
// ... existing success logic ...
} catch (error) {
callQueue.forEach(({ reject }) => reject(error));
}
})());
This ensures:
- All queued promises are properly rejected on error
- Runners never throw, so
Promise.all(runners) always succeeds
drainCallQueue() never rejects, eliminating the unhandled rejection
When the RPC provider fails (e.g., network error, 403 Forbidden, invalid API key), an unhandled promise rejection is thrown even when the error is properly caught in user code with try/catch.
Root Cause
drainCallQueue()is an async function called fromsetTimeoutcallbacks without awaiting or catching the returned promise:Lines 66-68 (drainInterval setter):
Lines 80-82 (queueCall):
When
this.subprovider.call()fails insidedrainCallQueue(), two issues occur:drainCallQueue()is unhandled (setTimeoutcallback doesn't catch it)queueCall()are never rejected because the error occurs before the resolve/reject logic runsReproduction
Proposed Fix
Wrap the subprovider.call() in try/catch inside the runner and properly reject all queued promises on error:
This ensures:
Promise.all(runners)always succeedsdrainCallQueue()never rejects, eliminating the unhandled rejection