Skip to content

bug: public RPC rate-limits (error -32011 / "request limit reached") break waitForTransactionReceipt, frontend approve calls, and keeper batch jobs — no official fallback documented #207

Description

@osr21

Summary

The Arc Testnet public RPC (rpc.testnet.arc.network) enforces a per-connection request rate limit that returns JSON-RPC error code -32011 ("request limit reached") when exceeded. This error surfaces in at least four distinct ways depending on which layer hits it, and each way has a different failure mode:

Layer How it appears Failure mode
Frontend / MetaMask eth_sendRawTransaction ContractFunctionRevertedError with reason "Request is being rate limited" User sees a false contract-revert toast — looks like their approve/write failed on-chain
viem waitForTransactionReceipt RpcRequestError wrapping -32011, hits the else → emit.reject branch immediately Receipt wait aborts on the first throttled poll even if the tx already confirmed — discussed in detail in #203
JSON-RPC batch requests Individual items in a batch can return -32011 while others succeed — not a full circuit breaker Partial batch failures are silently swallowed by callers that don't inspect per-item errors
Server-side viem clients (keeper, relay) RpcRequestError propagated up through readContract / writeContract Keeper tick fails, relay returns HTTP 500, session open fails

Error codes / fingerprints

JSON-RPC: { "code": -32011, "message": "request limit reached" }
Custom selector (in revert data): 0x4cef52
viem shortMessage: "RPC Request failed."
MetaMask toast: "The contract function '...' reverted with the following reason: Request is being rate limited."

Root cause

All Arc Testnet tooling defaults to a single RPC URL with no fallback and no retry on non-not-found RPC errors:

Under even moderate load (multiple concurrent DApp users, a keeper batch job, or a metered nanopayment session), the single public endpoint is enough to trigger -32011.

Reproduction

// viem — a single rate-limited poll terminates the entire wait
const client = createPublicClient({ transport: http("https://rpc.testnet.arc.network") });

await client.waitForTransactionReceipt({ hash: tx });
// If ANY single poll hits -32011 → RpcRequestError → immediate reject
// even if the tx is already confirmed on-chain

For the MetaMask / frontend path:

1. Connect MetaMask to Arc Testnet (Chain ID 5042002)
2. Call approve() on USDC (0x3600...0000) while another tab is also polling the same RPC
3. MetaMask shows: "The contract function 'approve' reverted with the following reason: Request is being rate limited."
4. The transaction may or may not have actually failed — the error is from the RPC, not the EVM

Workarounds (tested on Arc Testnet, Chain ID 5042002)

1. viem fallback() transport — distributes load across providers

import { createPublicClient, createWalletClient, fallback, http } from "viem";

const ARC_RPCS = [
  "https://rpc.testnet.arc.network",
  "https://rpc.drpc.testnet.arc.network",
  "https://rpc.quicknode.testnet.arc.network",
  "https://rpc.blockdaemon.testnet.arc.network",
];

export function arcTransport() {
  return fallback(ARC_RPCS.map((url) => http(url)));
}

export const publicClient = createPublicClient({
  chain: arcTestnet,
  transport: arcTransport(),
});

viem's fallback transport retries each RPC call against the next provider on transport-level errors, reducing how often -32011 fires. This is a mitigation, not a fix — a single -32011 on a sequential eth_getTransactionReceipt poll still terminates waitForTransactionReceipt regardless of which provider is behind the transport, because the rejection happens above the transport layer.

2. SDK-level retry for waitForTransactionReceipt

The load-bearing fix. The SDK (or DApp) must catch RpcRequestError / error code -32011 and retry the receipt lookup before treating it as a definitive failure:

import { RpcRequestError } from "viem";

async function waitForReceiptWithRetry(
  client: PublicClient,
  hash: `0x${string}`,
  maxAttempts = 5,
): Promise<TransactionReceipt> {
  for (let i = 0; i < maxAttempts; i++) {
    try {
      return await client.waitForTransactionReceipt({ hash, pollingInterval: 2000 });
    } catch (err) {
      const isRateLimit =
        err instanceof RpcRequestError && (err as any).code === -32011;
      if (isRateLimit && i < maxAttempts - 1) {
        await new Promise(r => setTimeout(r, 1000 * (i + 1))); // exponential back-off
        continue;
      }
      throw err; // real error or exhausted retries
    }
  }
  throw new Error("exhausted retries");
}

3. User-facing error normalisation

-32011 surfacing as a false contract-revert is confusing. A short check before showing errors:

function formatTxError(err: unknown): string {
  const raw = (err as any)?.shortMessage ?? (err as any)?.message ?? String(err);
  if (/rate.?limit|request limit reached/i.test(raw) || raw.includes("0x4cef52")) {
    return "Arc Testnet RPC is rate-limiting requests — please wait a few seconds and try again.";
  }
  if (/user rejected/i.test(raw)) return "Transaction cancelled.";
  return raw;
}

4. MetaMask / wallet — pass all RPC URLs in wallet_addEthereumChain

await window.ethereum.request({
  method: "wallet_addEthereumChain",
  params: [{
    chainId: "0x4CD052", // 5042002
    rpcUrls: [
      "https://rpc.testnet.arc.network",
      "https://rpc.drpc.testnet.arc.network",
      "https://rpc.quicknode.testnet.arc.network",
      "https://rpc.blockdaemon.testnet.arc.network",
    ],
    // ...
  }]
});

MetaMask uses the first URL and falls back to subsequent ones on failure.

Asks

  1. Document the alternative RPC endpoints (drpc, quicknode, blockdaemon) officially in this repo — ideally in the Arc Testnet reference table and in sample repo READMEs. Currently they're undocumented and builders have no way to know they exist.

  2. Fix waitForTransactionReceipt in @circle-fin/x402-batching — wrap the receipt fetch in SDK-level retry that distinguishes -32011 from definitive on-chain results. This is the same root cause as bug: GatewayClient mislabels receipt-wait failures as "Mint transaction failed" and never checks receipt.status for real reverts #203, just with a more specific error path.

  3. Raise the public RPC rate limit or provide a rate-limit-free builder endpoint — even a simple API-key scheme would be a significant improvement for teams running keeper jobs or load tests.

  4. Emit a warning in the BatchSizeLimitMiddleware path (see test(rpc): cover BatchSizeLimitMiddleware with max_entries=0 #154) when the request count approaches the rate limit, so operators can tune --rpc.batch-request-limit before they start seeing -32011.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions