Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions packages/bridge-core/src/core/polygonClient.retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,18 @@ describe('getPolygonPublicClient retry budget', () => {
});

it('keeps the whole backoff well inside a caller scan timeout', () => {
// The property that actually broke: a budget whose backoff alone can outlast the
// caller's timeout turns a recoverable throttle into an unrecoverable stall. Consumers
// bound a full chain scan at tens of seconds and issue many waves inside that, so one
// request's retry tail has to stay a small fraction of it.
// The property that actually broke: a budget whose backoff alone can outlast the caller's
// timeout turns a recoverable throttle into an unrecoverable stall. Consumers bound a full
// chain scan at tens of seconds (FAST_SCAN_TIMEOUT_MS is 30s in the app) and issue many
// waves inside that, so one request's retry tail has to stay a small fraction of it.
//
// The bound is derived, not picked. A per-second ceiling forces retryDelay >= 1000ms (see
// the test below), and viem's doubling backoff then makes delay * (2^count - 1) = 3000ms
// the FLOOR at two retries. So the ceiling here is a fraction of the consumer's timeout —
// ~1/6 of it — rather than the 2000ms this asserted when the delay was 250ms and crossing
// the rate period had not been accounted for.
const { retryCount, retryDelay } = getPolygonPublicClient().transport;
expect(backoffTotalMs(retryCount as number, retryDelay as number)).toBeLessThan(2_000);
expect(backoffTotalMs(retryCount as number, retryDelay as number)).toBeLessThanOrEqual(5_000);
});

it('pins the arithmetic this budget was chosen against', () => {
Expand All @@ -51,6 +57,16 @@ describe('getPolygonPublicClient retry budget', () => {
expect(backoffTotalMs(6, 250)).toBe(15_750);
});

it('spaces retries across the RATE PERIOD a per-second limit enforces', () => {
// The property, not the number. A provider's ceiling is per second, so a retry that lands
// inside the same second re-spends a budget that has not refilled. viem's first retry waits
// exactly retryDelay, so that delay is what decides whether attempt two gets a fresh
// window — at 250ms all three attempts land inside one second and none of them can pass.
const { retryDelay } = getPolygonPublicClient().transport;
expect(retryDelay as number).toBeGreaterThanOrEqual(1_000);
});


it('is still an HTTP transport, so a wave stays one round trip', () => {
// Unrelated to the count but the same cost contract, and cheap to lose in a refactor of
// the same options object.
Expand Down
13 changes: 12 additions & 1 deletion packages/bridge-core/src/core/polygonClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,18 @@ const ERC20_BALANCE_OF_ABI = [
// after one 429 — the consuming app's request gate — not in each request's own
// private retry loop.
const RPC_RETRY_COUNT = 2;
const RPC_RETRY_DELAY_MS = 250;

// Sized against a PER-SECOND ceiling, which is what a provider actually enforces (Alchemy's
// throttle names compute units per second). viem's backoff doubles from this value, so at 250ms
// the three attempts land at 0, 250 and 750ms — all inside the SAME exhausted second. Every
// retry then re-spends a budget that has not refilled, which is why "if you have retries
// enabled, you can safely ignore this message" was not holding for us.
//
// At 1000ms they land at 0, 1s and 3s: each attempt gets a fresh window. That is the whole
// point — a retry against a rate limit has to cross the rate's period, and no smaller value
// can. Worst case before a rejection surfaces is ~3s, still a small fraction of a caller's
// scan timeout, and only paid when the endpoint is actually shedding.
const RPC_RETRY_DELAY_MS = 1_000;

export function getPolygonPublicClient(): PublicClient {
const chain = config.polygon.chainId === polygon.id ? polygon : polygonAmoy;
Expand Down
Loading