fix(perps): coalesce candle subscribe races to cut HL 429s cp-13.28.0 - #41917
Conversation
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
Builds ready [d5d15a1] [reused from 4ede4f5]
⚡ Performance Benchmarks (Total: 🟢 6 pass · 🟡 9 warn · 🔴 0 fail)
Bundle size diffs
|
Worker reportTAT-2986 — Investigate residual 429s on Extension PerpsTicket: TAT-2986 SummaryReproduced the residual HTTP 429 rate-limit live on dev1 (perps-funded): 3 × 429 across 4 rapid-switch probe runs (~23% rate) on ChangesNo production code changes. This is an investigation-only PR.
Reproduction evidence4 rapid-switch runs, click-driven via
The race signature (run-2)Two 429s in a 550ms window — rate-limit saturated by prior coalescing of candle subscriptions whose Hypothesis ranking
Fix sketchLocation:
Combined effect: 6-market cycle in ~1.2s produces one candle request for the landing symbol instead of six activate/deactivate pairs. Preserves AbortController guard for genuine user-left-chart case. Test plan
Environment notes
Self-Review Fixes
|
PerpsLayout mount, chart mount, and stream activation on market-detail navigation race to call subscribeToCandles with the same key. Each call fires a candleSnapshot REST. The vendor client aborts the in-flight one but HL has already charged weight for every request sent, contributing to the residual 429s. Three defense layers: - Idempotent activate at the bridge: same symbol/interval key short-circuits a duplicate activate. - 150ms deferred teardown: deactivate followed by activate within 150ms cancels the pending teardown instead of tearing down and re-subscribing. - 120ms leading-edge debounce in CandleStreamChannel for back-to-back subscribers requesting the same key. Race-probe A/B: scenarios A (2x activate, 50ms apart) and B (deactivate + re-activate within 150ms) drop from 2 REST requests to 1. Scenario C (300ms gap, outside defer window) remains 2 as expected - no over-coalescing.
✨ Files requiring CODEOWNER review ✨👨🔧 @MetaMask/perps (13 files, +1008 -63)
|
Builds ready [e8b5125]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 8 warn · 🔴 0 fail)
Bundle size diffs
|
Clears lastConnectAt when the channel disconnects or explicitly reconnects so the 120ms leading-edge debounce only coalesces genuine connect-fail-connect thrashing, not legitimate re-subscribe after full teardown. Fixes CandleStreamChannel unit tests.
Builds ready [4847c2d]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 8 warn · 🔴 0 fail)
Bundle size diffs
|
Rapid market switches (e.g. xyz:AAPL → xyz:GOLD) could land on the update effect when the new series happened to have the same candle count as the previous market. That pushed the new symbol's candle through the incremental `.update()` path, feeding lightweight-charts a data point older than its last stored time, which throws "Cannot update oldest data, last time=..., new time=..." and trips the MetaMask error boundary. Track the last-filled symbol + interval and force a full `setData()` replace when either changes, reserving the live-tick and append paths for same-series updates only. Also scroll to real-time on identity changes so the chart opens aligned.
The perps activity page mounts fire four HL REST calls in parallel — getUserHistory, getOrderFills, getOrders, getFunding — on every mount. Rapid navigation into/out of the activity page duplicated that burst and chewed into the 1200 wgt/min HL budget, contributing to residual 429s after the candle-subscribe fix. Add a module-level coalescing helper (10 s TTL + in-flight dedup) and wire it around the four calls in useUserHistory and usePerpsTransactionHistory. Explicit `refetch()` paths invalidate the cache so pull-to-refresh still hits the network. Also make usePerpsTransactionHistory start in loading state when it will auto-fetch on mount so consumers render a skeleton instead of flashing "No transactions yet" on the frame before the effect fires.
- coalesceBackgroundRequest: invalidate() now evicts cache only. Dropping the in-flight entry caused duplicate HL requests when a second caller (e.g. activity-page forceFreshOnMount) invalidated while another hook instance's fetch was still running. The in-flight snapshot is by definition the freshest server data, so concurrent callers must share it instead of firing a duplicate request. - usePerpsTransactionHistory / useUserHistory: add fetchGenerationRef so resolutions from a pre-scope-change fetch cannot overwrite state for the newer (account / testnet / time-range) scope after a rapid switch. - Switch coalesce cache keys from JSON.stringify to pipe-delimited strings; CaipAccountId and perpsScopeKey contain ':' but never '|', so fields remain unambiguous without the per-render encode cost. - Cross-reference CANDLE_TEARDOWN_DEFER_MS (150 ms bridge) and CONNECT_DEBOUNCE_MS (120 ms UI) so the 30 ms margin is documented.
…nt switch) - Add clearAllCoalescedRequests() that drops both the TTL cache and any in-flight promises. Unlike invalidateCoalescedRequest() (same-scope refetch, preserves in-flight), a scope change makes any in-flight response definitionally wrong — the next caller must start fresh. - Wire into PerpsStreamManager's three scope-teardown paths: address change in ensureInitialized(), clearAllCaches(), and reset(). This guarantees no previous-scope response can leak into the new scope after a wallet lock / sign-out / account switch / testnet toggle. - Clarify the 10s TTL rationale: passive consumers (recent-activity preview) are the only readers; top-level consumers bypass via forceFreshOnMount.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 09ea79a. Configure here.
… reconnect When a subscriber comes back n ms after disconnect(), connect() was scheduling another full CONNECT_DEBOUNCE_MS wait instead of the remaining CONNECT_DEBOUNCE_MS - n. Any remount gap above ~30 ms pushed the UI reconnect past the bridge's 150 ms teardown defer, which committed the disconnect and forced a fresh perpsActivateCandleStream — the exact unsubscribe/resubscribe burst this debounce exists to coalesce. The debounce window is anchored to lastDisconnectAt, so the timer must fire at (lastDisconnectAt + CONNECT_DEBOUNCE_MS) regardless of when the resubscribe arrives inside that window.
|
Builds ready [f851e9d]
⚡ Performance Benchmarks (Total: 🟢 7 pass · 🟡 8 warn · 🔴 0 fail)
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
🧪 Validation RunVerdict: ✅ the added tests have power over the coalescing guard — Claim: in-flight candle subscriptions are shared rather than duplicated. head Note Trial run of the MetaMask evidence skills — The sharing happens at Falsification probe —
|
| Arm | Mutation | Result |
|---|---|---|
| A — baseline | none | Test Suites: 1 passed, 1 total Tests: 10 passed, 10 total |
| B — mutant | ui/hooks/perps/coalesceBackgroundRequest.ts:54 replaced |
Test Suites: 1 failed, 1 total Tests: 2 failed, 8 passed, 10 total |
The suite fails when the mechanism is removed and passes when restored, running the same 10 tests in both arms. The test has power.
Failing under mutation:
- ● coalesceBackgroundRequest › dedups concurrent callers with the same key into one in-flight request
- ● coalesceBackgroundRequest › invalidate() mid-flight preserves the in-flight promise so concurrent callers coalesce into one request
Produced by falsify-probe.sh at 20642e25bec1968d8ef71f75f6afc82a6d6dd6dc · node v24.13.1 · yarn.lock 3d4485d59328134a · 0 tracked changes. Run: https://github.com/MajorLift/metamask-skills/actions/runs/30750999191 — logs and artifacts attached there.
Follows from the arm above
- Disabling the in-flight lookup fails exactly 2 of 10 tests, and the other 8 still pass — the suite is discriminating, not collapsing.
- The same 10 tests run in both arms, so nothing here is a module that failed to load.
Open for review: this mutates one line of one file. It establishes that the tests can see the coalescing guard, not that the guard is reachable from every path that subscribes, nor that sharing is the right behaviour under an error in the shared promise.



Description
Under rapid perps market switching, Hyperliquid returned 429s on candle requests and activity-page REST calls. This PR removes the client-side races that caused them.
Problem 1 — candle subscribe race. Opening a market-detail page triggers three components that each call
subscribeToCandlesfor the same{symbol, interval}key in the same tick:PerpsLayout,PerpsTVChart, anduseCandleStream. Each call fires aPOST /info { type: "candleSnapshot" }to Hyperliquid. The vendor client (HyperLiquidClientService) aborts duplicates viaAbortController(follow-up to MetaMask/core#28141), but the server has already accepted the request and charged weight against the 1200 wgt/min per-IP limit. On rapid navigation each market switch leaks 1–2 aborted-but-charged REST calls, which is what tips us over 429.Problem 2 — activity-page REST burst.
usePerpsTransactionHistoryfiresperpsGetUserHistory,perpsGetOrderFills,perpsGetOrders, andperpsGetFundinginPromise.allon mount, with no in-flight dedup or cache. Rapid tab switches multiply the burst.useUserHistoryhas the same shape forperpsGetUserHistoryalone.Fix — coalesce at the origin of each burst.
app/scripts/controllers/perps/perps-stream-bridge.tsperpsActivateCandleStreamfor a live key short-circuits. AperpsDeactivateCandleStreamwaits 150ms; a matching activate inside that window cancels the teardown instead of tearing down and rebuilding.ui/providers/perps/CandleStreamChannel.tslastConnectAtis cleared on full disconnect/reconnect so legitimate re-subscribes after teardown fire immediately.ui/hooks/perps/useUserHistory.ts,ui/hooks/perps/usePerpsTransactionHistory.tsviaui/hooks/perps/coalesceBackgroundRequest.tsrefetch()bypasses the cache so user-initiated refresh still re-runs.setDataon symbol/interval changeui/components/app/perps/TradingViewChart/CandlestickChart.tsxAll windows are scoped — outside them the code path is unchanged, so there is no over-coalescing of genuinely new subscriptions.
Changelog
CHANGELOG entry: Fixed a bug that caused some Hyperliquid requests to be rate-limited (429) when rapidly switching between perps markets.
Related issues
Fixes: #41926 TAT-2986
Manual testing steps
PORT=9015 yarn start(watch mode) oryarn build:testfor a clean one-shot build.dist/chrome/. Unlock a perps account.api.hyperliquid.xyzshould appear.Screenshots/Recordings
Evidence below is extension-only, measured via Chrome DevTools Protocol attached to the service worker + home-page targets. A scripted 10-market rotation drives the same code path as manual rapid navigation, at human-realistic pacing (1500 ms dwell per market), with a mid-rotation activity-page visit to exercise the transaction-history fetch burst.
Probe shape (identical between runs): 2 passes × 10 markets × 1500 ms dwell + 1 activity visit per pass. Counts every
api.hyperliquid.xyzresponse + every matching console error from sw and home.4ede4f596d)42db5f07ad)No cross-platform comparison is claimed — the mobile build was not instrumented with the same network probe.
Recipe JSON — rate-limit-10-market-stress.json (click to expand)
The scripted rotation above runs via an internal CDP recipe runner that is not yet available to external reviewers. The recipe graph itself is reproducible by hand: each
run{1,2}-<symbol>node isnavigate → 1500ms wait → wait forperps-market-detail-pagetest-id, with a mid-rotation detour through/perps/activity.{ "title": "Perps 10-market rapid-switch rate-limit proof (core + HIP-3)", "validate": { "workflow": { "pre_conditions": ["wallet.unlocked"], "entry": "setup-install-rl-probe", "teardown": [ { "id": "teardown-stop-rl-probe", "action": "cdp_probe", "phase": "stop", "name": "hl-rate-limit" } ], "nodes": { "setup-install-rl-probe": { "action": "cdp_probe", "phase": "start", "name": "hl-rate-limit", "scope": "both", "url_pattern": "api\\.hyperliquid\\.xyz", "message_pattern": "WebSocketRequestError|HttpRequestError|Too Many Requests|\\b429\\b", "status_match": [429], "stream": true, "next": "setup-nav-perps" }, "setup-nav-perps": { "action": "call", "ref": "perps/navigate-perps-tab", "next": "run1-btc" }, "run1-btc": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "BTC" }, "next": "run1-btc-dwell" }, "run1-btc-dwell": { "action": "wait", "ms": 1500, "next": "run1-btc-assert" }, "run1-btc-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run1-eth" }, "run1-eth": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "ETH" }, "next": "run1-eth-dwell" }, "run1-eth-dwell": { "action": "wait", "ms": 1500, "next": "run1-eth-assert" }, "run1-eth-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run1-sol" }, "run1-sol": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "SOL" }, "next": "run1-sol-dwell" }, "run1-sol-dwell": { "action": "wait", "ms": 1500, "next": "run1-sol-assert" }, "run1-sol-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run1-hype" }, "run1-hype": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "HYPE" }, "next": "run1-hype-dwell" }, "run1-hype-dwell": { "action": "wait", "ms": 1500, "next": "run1-hype-assert" }, "run1-hype-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run1-pump" }, "run1-pump": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "PUMP" }, "next": "run1-pump-dwell" }, "run1-pump-dwell": { "action": "wait", "ms": 1500, "next": "run1-pump-assert" }, "run1-pump-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run1-activity-nav" }, "run1-activity-nav": { "action": "eval_sync", "expression": "(function(){try{location.hash='#/perps/activity';return JSON.stringify({navigated:true});}catch(e){return JSON.stringify({navigated:false,error:String(e&&e.message||e)});}})()", "assert": { "all": [ { "operator": "eq", "field": "navigated", "value": true } ] }, "next": "run1-activity-dwell" }, "run1-activity-dwell": { "action": "wait", "ms": 1500, "next": "run1-activity-assert" }, "run1-activity-assert": { "action": "wait_for", "test_id": "perps-activity-page", "timeout_ms": 3000, "next": "run1-tsla" }, "run1-tsla": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "xyz:TSLA" }, "next": "run1-tsla-dwell" }, "run1-tsla-dwell": { "action": "wait", "ms": 1500, "next": "run1-tsla-assert" }, "run1-tsla-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run1-nvda" }, "run1-nvda": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "xyz:NVDA" }, "next": "run1-nvda-dwell" }, "run1-nvda-dwell": { "action": "wait", "ms": 1500, "next": "run1-nvda-assert" }, "run1-nvda-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run1-aapl" }, "run1-aapl": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "xyz:AAPL" }, "next": "run1-aapl-dwell" }, "run1-aapl-dwell": { "action": "wait", "ms": 1500, "next": "run1-aapl-assert" }, "run1-aapl-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run1-gold" }, "run1-gold": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "xyz:GOLD" }, "next": "run1-gold-dwell" }, "run1-gold-dwell": { "action": "wait", "ms": 1500, "next": "run1-gold-assert" }, "run1-gold-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run1-coin" }, "run1-coin": { "action": "call", "ref": "perps/navigate-to-market-detail", "params": { "symbol": "xyz:COIN" }, "next": "run1-coin-dwell" }, "run1-coin-dwell": { "action": "wait", "ms": 1500, "next": "run1-coin-assert" }, "run1-coin-assert": { "action": "wait_for", "test_id": "perps-market-detail-page", "timeout_ms": 3000, "next": "run2-btc" }, "// run2-*": "same shape as run1-* over [BTC, ETH, SOL, HYPE, PUMP, (activity visit), xyz:TSLA, xyz:NVDA, xyz:AAPL, xyz:GOLD, xyz:COIN]", "ac-no-error-boundary": { "action": "eval_sync", "expression": "(function(){var hasError=!!document.querySelector('[data-testid=\"error-boundary\"], [data-testid=\"perps-error-fallback\"]');var onDetail=!!document.querySelector('[data-testid=\"perps-market-detail-page\"]');return JSON.stringify({hasError:hasError,onDetail:onDetail});})()", "assert": { "all": [ { "operator": "eq", "field": "hasError", "value": false }, { "operator": "eq", "field": "onDetail", "value": true } ] }, "next": "ac-rate-limit-probe" }, "ac-rate-limit-probe": { "action": "cdp_probe", "phase": "stop", "name": "hl-rate-limit", "assert": { "all": [ { "operator": "eq", "field": "network.matchedStatus", "value": 0 }, { "operator": "eq", "field": "messages.total", "value": 0 } ] }, "next": "done" }, "done": { "action": "end", "status": "pass" } } } } }Reproducing by hand: navigate to each symbol in
[BTC, ETH, SOL, HYPE, PUMP], wait ~1.5 s each, open#/perps/activity, wait ~1.5 s, then[xyz:TSLA, xyz:NVDA, xyz:AAPL, xyz:GOLD, xyz:COIN]— and repeat once. With DevTools Network open, filter toapi.hyperliquid.xyz. Pre-fix you should see a handful of 429 responses; post-fix none.Pre-merge author checklist
Pre-merge reviewer checklist
Note
Medium Risk
Touches perps streaming lifecycle and transaction-history fetching, where timing/teardown/coalescing bugs could lead to stale UI data or missed/unexpected subscriptions despite added test coverage.
Overview
Reduces Hyperliquid rate-limit bursts during rapid perps navigation by making candle streaming idempotent and debounce/coalesced across layers.
In
PerpsStreamBridge,perpsActivateCandleStreamnow dedups concurrent activations, cancels pending teardowns, and defersperpsDeactivateCandleStreamteardown by 150ms (with generation-based guards ondestroy()). In the UI,CandleStreamChanneladds a 120ms reconnect debounce anchored to the last disconnect, and the candlestick chart forces fullsetDataon symbol/interval changes to avoid incremental-update crashes.Adds a reusable
coalesceBackgroundRequesthelper (in-flight dedup + short TTL cache + scoped clearing) and applies it touseUserHistoryandusePerpsTransactionHistory, including cache invalidation on explicitrefetch()and aforceFreshOnMountoption used byPerpsActivityPage;PerpsStreamManagerclears coalesced requests on scope resets. Tests are expanded accordingly.Reviewed by Cursor Bugbot for commit f851e9d. Bugbot is set up for automated code reviews on this repo. Configure here.