Skip to content

Unhandled promise rejection when RPC fails in drainCallQueue #9

@alexszolowicz-blockether

Description

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:

  1. The rejection from drainCallQueue() is unhandled (setTimeout callback doesn't catch it)
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions