Robust rate limiting: concurrency pool, graceful failures, longer backoff

- Add pooled() concurrency limiter (2 chains data, 3 chains detection)
- fetchJSON returns null on exhausted retries instead of throwing
- Backoff starts at 2s, doubles up to 32s, 5 retries
- fetchAllChainsData: failed chains skipped, partial data still renders
- fetchChainData: 300ms delay between requests within same chain
- Cache bust v4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 07:51:40 -07:00
parent 1c2e0fc9bb
commit 391db9aa20
4 changed files with 54 additions and 20 deletions

View File

@ -31,20 +31,47 @@ const SafeAPI = (() => {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
async function fetchJSON(url, retries = 4) { /**
* Fetch JSON with retry + exponential backoff on 429.
* Returns null on 404. Throws on other errors.
*/
async function fetchJSON(url, retries = 5) {
for (let attempt = 0; attempt <= retries; attempt++) { for (let attempt = 0; attempt <= retries; attempt++) {
const res = await fetch(url); const res = await fetch(url);
if (res.status === 404) return null; if (res.status === 404) return null;
if (res.status === 429) { if (res.status === 429) {
const delay = Math.min(1000 * Math.pow(2, attempt), 10000); // Start at 2s, then 4s, 8s, 16s, 32s
console.warn(`Rate limited (429), retrying in ${delay}ms... (${url})`); const delay = Math.min(2000 * Math.pow(2, attempt), 32000);
console.warn(`429 rate limited, retry ${attempt + 1}/${retries} in ${delay}ms...`);
await sleep(delay); await sleep(delay);
continue; continue;
} }
if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText} (${url})`); if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText} (${url})`);
return res.json(); return res.json();
} }
throw new Error(`Rate limited after ${retries} retries: ${url}`); // Exhausted retries — return null instead of throwing to be graceful
console.error(`Rate limited after ${retries} retries, skipping: ${url}`);
return null;
}
/**
* Run async tasks with a concurrency limit.
* Returns array of results in original order.
*/
async function pooled(tasks, concurrency = 2) {
const results = new Array(tasks.length);
let next = 0;
async function worker() {
while (next < tasks.length) {
const i = next++;
results[i] = await tasks[i]();
}
}
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker());
await Promise.all(workers);
return results;
} }
// ─── Core API Methods ────────────────────────────────────────── // ─── Core API Methods ──────────────────────────────────────────
@ -83,7 +110,6 @@ const SafeAPI = (() => {
logoUri: b.token.logoUri, logoUri: b.token.logoUri,
} : null, } : null,
balance: b.balance, balance: b.balance,
// Human-readable balance
balanceFormatted: b.token balanceFormatted: b.token
? (parseFloat(b.balance) / Math.pow(10, b.token.decimals)).toFixed(b.token.decimals > 6 ? 4 : 2) ? (parseFloat(b.balance) / Math.pow(10, b.token.decimals)).toFixed(b.token.decimals > 6 ? 4 : 2)
: (parseFloat(b.balance) / 1e18).toFixed(4), : (parseFloat(b.balance) / 1e18).toFixed(4),
@ -105,7 +131,6 @@ const SafeAPI = (() => {
if (!data || !data.results) break; if (!data || !data.results) break;
allTxs.push(...data.results); allTxs.push(...data.results);
url = data.next; url = data.next;
// Safety: cap at 1000 transactions
if (allTxs.length >= 1000) break; if (allTxs.length >= 1000) break;
} }
return allTxs; return allTxs;
@ -147,33 +172,37 @@ const SafeAPI = (() => {
/** /**
* Detect which chains have a Safe deployed for this address. * Detect which chains have a Safe deployed for this address.
* Each chain has its own API server, so we can check all in parallel. * Uses concurrency pool of 3 to avoid global rate limits.
* Returns array of { chainId, chain, safeInfo } * Returns array of { chainId, chain, safeInfo }
*/ */
async function detectSafeChains(address) { async function detectSafeChains(address) {
const checks = Object.entries(CHAINS).map(async ([chainId, chain]) => { const entries = Object.entries(CHAINS);
const tasks = entries.map(([chainId, chain]) => async () => {
try { try {
const info = await getSafeInfo(address, parseInt(chainId)); const info = await getSafeInfo(address, parseInt(chainId));
if (info) return { chainId: parseInt(chainId), chain, safeInfo: info }; if (info) return { chainId: parseInt(chainId), chain, safeInfo: info };
} catch (e) { } catch (e) {
// Chain doesn't have this Safe or API error - skip // skip
} }
return null; return null;
}); });
const results = await Promise.all(checks); const results = await pooled(tasks, 3);
return results.filter(Boolean); return results.filter(Boolean);
} }
/** /**
* Fetch comprehensive wallet data for a single chain. * Fetch comprehensive wallet data for a single chain.
* Sequential within the same chain to respect per-server rate limits. * Sequential within the same chain with small delays.
* Returns { info, balances, outgoing, incoming } * Returns { chainId, info, balances, outgoing, incoming }
*/ */
async function fetchChainData(address, chainId) { async function fetchChainData(address, chainId) {
const info = await getSafeInfo(address, chainId); const info = await getSafeInfo(address, chainId);
await sleep(300);
const balances = await getBalances(address, chainId); const balances = await getBalances(address, chainId);
await sleep(300);
const outgoing = await getAllMultisigTransactions(address, chainId); const outgoing = await getAllMultisigTransactions(address, chainId);
await sleep(300);
const incoming = await getAllIncomingTransfers(address, chainId); const incoming = await getAllIncomingTransfers(address, chainId);
return { chainId, info, balances, outgoing, incoming }; return { chainId, info, balances, outgoing, incoming };
@ -181,18 +210,23 @@ const SafeAPI = (() => {
/** /**
* Fetch wallet data across all detected chains. * Fetch wallet data across all detected chains.
* Parallel across chains (different servers), sequential within each chain. * Concurrency pool of 2 chains at a time fast but gentle.
* Failures are non-fatal: failed chains are skipped.
* Returns Map<chainId, chainData> * Returns Map<chainId, chainData>
*/ */
async function fetchAllChainsData(address, detectedChains) { async function fetchAllChainsData(address, detectedChains) {
const dataMap = new Map(); const dataMap = new Map();
const fetches = detectedChains.map(async ({ chainId }) => { const tasks = detectedChains.map(({ chainId }) => async () => {
const data = await fetchChainData(address, chainId); try {
dataMap.set(chainId, data); const data = await fetchChainData(address, chainId);
dataMap.set(chainId, data);
} catch (e) {
console.warn(`Failed to fetch chain ${chainId}, skipping:`, e.message);
}
}); });
await Promise.all(fetches); await pooled(tasks, 2);
return dataMap; return dataMap;
} }

View File

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Chain Flow | rWallet.online</title> <title>Multi-Chain Flow | rWallet.online</title>
<script src="https://d3js.org/d3.v7.min.js"></script> <script src="https://d3js.org/d3.v7.min.js"></script>
<script src="js/safe-api.js?v=3"></script> <script src="js/safe-api.js?v=4"></script>
<script src="js/data-transform.js?v=2"></script> <script src="js/data-transform.js?v=2"></script>
<script src="js/router.js?v=2"></script> <script src="js/router.js?v=2"></script>
<style> <style>

View File

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Balance River | rWallet.online</title> <title>Balance River | rWallet.online</title>
<script src="https://d3js.org/d3.v7.min.js"></script> <script src="https://d3js.org/d3.v7.min.js"></script>
<script src="js/safe-api.js?v=3"></script> <script src="js/safe-api.js?v=4"></script>
<script src="js/data-transform.js?v=2"></script> <script src="js/data-transform.js?v=2"></script>
<script src="js/router.js?v=2"></script> <script src="js/router.js?v=2"></script>
<style> <style>

View File

@ -6,7 +6,7 @@
<title>Single-Chain Flow | rWallet.online</title> <title>Single-Chain Flow | rWallet.online</title>
<script src="https://d3js.org/d3.v7.min.js"></script> <script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js"></script>
<script src="js/safe-api.js?v=3"></script> <script src="js/safe-api.js?v=4"></script>
<script src="js/data-transform.js?v=2"></script> <script src="js/data-transform.js?v=2"></script>
<script src="js/router.js?v=2"></script> <script src="js/router.js?v=2"></script>
<style> <style>