From f414cb7e3e68cc6cf78a566c8a686d165311a695 Mon Sep 17 00:00:00 2001 From: Sybi Date: Sat, 10 Oct 2026 17:38:14 +0000 Subject: [PATCH] Add RPC client with retries, endpoint config and mock RPC helper --- README.md | 19 +++- src/cli.js | 21 +++- src/endpoint.js | 75 +++++++++++++ src/rpc.js | 193 +++++++++++++++++++++++++++++++++ tests/cli.test.js | 22 +++- tests/endpoint.test.js | 82 ++++++++++++++ tests/helpers/mock-rpc.js | 76 +++++++++++++ tests/rpc.test.js | 220 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 702 insertions(+), 6 deletions(-) create mode 100644 src/endpoint.js create mode 100644 src/rpc.js create mode 100644 tests/endpoint.test.js create mode 100644 tests/helpers/mock-rpc.js create mode 100644 tests/rpc.test.js diff --git a/README.md b/README.md index a389ccf..b640a4a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Check if a Solana program is upgradeable, who controls it, and when it was last deployed. -Node 18+. Early scaffold: program ID validation works, the on-chain lookup is coming. +Node 18+. Early scaffold: program ID validation and the RPC connection work, the on-chain lookup is coming. ## Usage @@ -17,14 +17,27 @@ npx sol-program-info | `-h`, `--help` | Show help | | `-v`, `--version` | Show the version | -Exit codes: `0` success, `1` invalid program ID, `2` bad usage. +Results go to stdout, status messages go to stderr. Exit codes: `0` success, `1` invalid program ID or RPC endpoint setting, `2` bad usage. + +## Use your own RPC + +Public RPCs rate-limit heavily. Set your own endpoint: + +```bash +export SOL_PROGRAM_INFO_RPC="https://your-rpc-endpoint" +``` + +Only the hostname is ever printed, never the full URL, so API keys stay out of your terminal output. + +When the endpoint is busy, requests are retried with backoff and a `Retry-After` header is respected. A rejected API key (HTTP 401 or 403) or a wrong URL (HTTP 404) stops right away with a plain-English message instead of retrying. ## Roadmap - [x] Program ID validation +- [x] RPC client with retries and clear errors - [ ] Look up the program on-chain: upgradeable or immutable - [ ] Upgrade authority, last deploy time and size -- [ ] `--json`, clear errors and a plain-English verdict +- [ ] `--json` and a plain-English verdict ## Development diff --git a/src/cli.js b/src/cli.js index 5e5890d..9edbdb9 100644 --- a/src/cli.js +++ b/src/cli.js @@ -3,6 +3,7 @@ const pkg = require('../package.json'); const { AddressError, validateProgramId } = require('./address'); const { parseArgs } = require('./args'); +const { EndpointError, RPC_ENV, resolveEndpoint } = require('./endpoint'); const HELP = `sol-program-info v${pkg.version} @@ -16,9 +17,16 @@ Options: -h, --help Show this help -v, --version Show the version +Environment: + ${RPC_ENV} Your own RPC endpoint (recommended; public RPCs + rate-limit heavily) + +Output: + Results go to stdout, status messages go to stderr. + Exit codes: 0 success - 1 invalid program ID + 1 invalid program ID or RPC endpoint setting 2 bad usage `; @@ -28,6 +36,7 @@ Exit codes: async function run(argv, io = {}) { const stdout = io.stdout || process.stdout; const stderr = io.stderr || process.stderr; + const env = io.env || process.env; const args = parseArgs(argv); @@ -66,6 +75,16 @@ async function run(argv, io = {}) { return 1; } + let endpoint; + try { + endpoint = resolveEndpoint(env); + } catch (error) { + if (!(error instanceof EndpointError)) throw error; + stderr.write(`${error.message}\n`); + return 1; + } + + stderr.write(`Using ${endpoint.host}.\n`); stdout.write(`${programId} is a valid program ID. Program lookup is not built yet.\n`); return 0; } diff --git a/src/endpoint.js b/src/endpoint.js new file mode 100644 index 0000000..66b0c8d --- /dev/null +++ b/src/endpoint.js @@ -0,0 +1,75 @@ +'use strict'; + +const DEFAULT_RPC = 'https://api.mainnet-beta.solana.com'; +const RPC_ENV = 'SOL_PROGRAM_INFO_RPC'; +const PUBLIC_HOSTS = new Set([ + 'api.mainnet-beta.solana.com', + 'api.devnet.solana.com', + 'api.testnet.solana.com', +]); + +class EndpointError extends Error { + constructor(message) { + super(message); + this.name = 'EndpointError'; + } +} + +/** + * Only the hostname is ever printed. RPC URLs often carry an API key + * in the path or query string, so the full URL never reaches the output. + */ +function hostLabel(url) { + try { + return new URL(url).hostname; + } catch { + return 'the endpoint'; + } +} + +/** + * Pick the RPC endpoint: SOL_PROGRAM_INFO_RPC if set, else the public one. + * Error messages never include the value, because it may contain an API key. + * isPublic is true for Solana's free public endpoints, which rate-limit heavily. + */ +function resolveEndpoint(env = process.env) { + const value = typeof env[RPC_ENV] === 'string' ? env[RPC_ENV].trim() : ''; + + if (value === '') { + return { rpcUrl: DEFAULT_RPC, host: hostLabel(DEFAULT_RPC), isPublic: true }; + } + + let parsed; + try { + parsed = new URL(value); + } catch { + throw new EndpointError(`${RPC_ENV} is not a valid URL.`); + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new EndpointError(`${RPC_ENV} must start with http:// or https://.`); + } + + return { + rpcUrl: parsed.toString(), + host: parsed.hostname, + isPublic: PUBLIC_HOSTS.has(parsed.hostname), + }; +} + +/** One-line advice for when the endpoint is throttling us. */ +function rateLimitHint(endpoint) { + if (endpoint.isPublic) { + return `The public RPC rate-limits heavily. Set ${RPC_ENV} to your own endpoint to avoid this.`; + } + return "Your RPC endpoint is rate limiting requests. Wait a moment and check your plan's limits."; +} + +module.exports = { + DEFAULT_RPC, + EndpointError, + RPC_ENV, + hostLabel, + rateLimitHint, + resolveEndpoint, +}; \ No newline at end of file diff --git a/src/rpc.js b/src/rpc.js new file mode 100644 index 0000000..5f425d2 --- /dev/null +++ b/src/rpc.js @@ -0,0 +1,193 @@ +'use strict'; + +const DEFAULT_OPTIONS = { + attempts: 4, + initialDelayMs: 500, + maxDelayMs: 8000, + timeoutMs: 15000, +}; + +const RETRYABLE = new Set(['rate-limited', 'server', 'network', 'timeout']); + +/** + * Something went wrong talking to the RPC. `kind` is one of: + * rate-limited, auth, not-found, server, network, timeout, rpc, bad-response. + * The message is plain English and never contains the endpoint URL. + */ +class RpcError extends Error { + constructor(message, kind) { + super(message); + this.name = 'RpcError'; + this.kind = kind; + this.rateLimited = kind === 'rate-limited'; + this.retryable = RETRYABLE.has(kind); + this.retryAfterMs = null; + } +} + +const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Exponential backoff with +/-25% jitter, never above maxDelayMs. */ +function backoffDelay(attempt, options = {}, random = Math.random) { + const { initialDelayMs, maxDelayMs } = { ...DEFAULT_OPTIONS, ...options }; + const base = Math.min(maxDelayMs, initialDelayMs * 2 ** attempt); + return Math.min(maxDelayMs, Math.round(base * (0.75 + random() * 0.5))); +} + +/** A Retry-After header is seconds or an HTTP date. Returns milliseconds, or null. */ +function parseRetryAfter(value, now = Date.now()) { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + if (/^\d+$/.test(text)) return Number(text) * 1000; + const date = Date.parse(text); + if (Number.isNaN(date)) return null; + return Math.max(0, date - now); +} + +function looksRateLimited(code, message) { + return code === 429 || /rate.?limit|too many requests/i.test(String(message || '')); +} + +/** Turn a thrown fetch error into a plain-English RpcError. */ +function classifyNetworkError(error) { + if (error && error.name === 'AbortError') { + return new RpcError('the endpoint did not answer in time', 'timeout'); + } + + const code = (error && error.cause && error.cause.code) || (error && error.code) || ''; + + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { + return new RpcError('could not find the endpoint host (DNS lookup failed)', 'network'); + } + if (code === 'ECONNREFUSED') { + return new RpcError('the endpoint refused the connection', 'network'); + } + if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'UND_ERR_HEADERS_TIMEOUT') { + return new RpcError('the connection timed out', 'timeout'); + } + if (code === 'ECONNRESET' || code === 'UND_ERR_SOCKET') { + return new RpcError('the connection was reset', 'network'); + } + return new RpcError('could not reach the endpoint', 'network'); +} + +/** Turn an HTTP response and its body text into a result, or throw an RpcError. */ +function interpret(response, text) { + const status = response.status; + + if (status === 429) { + const error = new RpcError('the endpoint is rate limiting requests (HTTP 429)', 'rate-limited'); + error.retryAfterMs = parseRetryAfter(response.headers.get('retry-after')); + throw error; + } + if (status === 401 || status === 403) { + throw new RpcError( + `the endpoint rejected the request (HTTP ${status}); check the API key in your endpoint URL`, + 'auth' + ); + } + if (status === 404) { + throw new RpcError('the endpoint was not found (HTTP 404); check the URL', 'not-found'); + } + if (status >= 500) { + throw new RpcError(`the endpoint had a server error (HTTP ${status})`, 'server'); + } + if (status < 200 || status >= 300) { + throw new RpcError(`the endpoint answered with HTTP ${status}`, 'bad-response'); + } + + let body; + try { + body = JSON.parse(text); + } catch { + throw new RpcError('the endpoint did not return JSON', 'bad-response'); + } + if (!body || typeof body !== 'object') { + throw new RpcError('the endpoint did not return JSON', 'bad-response'); + } + + if (body.error) { + const message = typeof body.error.message === 'string' ? body.error.message : 'unknown error'; + if (looksRateLimited(body.error.code, message)) { + throw new RpcError('the endpoint is rate limiting requests', 'rate-limited'); + } + throw new RpcError(message, 'rpc'); + } + + if (!('result' in body)) { + throw new RpcError('the endpoint answer had no result', 'bad-response'); + } + return body.result; +} + +/** + * A small JSON-RPC client over fetch. + * + * call(method, params) resolves to the result, or rejects with an RpcError. + * Rate limits, server errors, network errors and timeouts are retried with + * backoff (a Retry-After header is respected); everything else fails at once. + * onRetry({ attempt, delayMs, error }) is called before each wait. + */ +function createRpcClient(options) { + const settings = { ...DEFAULT_OPTIONS, ...options }; + const { url, onRetry = () => {} } = settings; + const fetchImpl = settings.fetch || globalThis.fetch; + const sleep = settings.sleep || sleepFor; + const random = settings.random || Math.random; + let nextId = 1; + + async function once(method, params) { + const id = nextId; + nextId += 1; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), settings.timeoutMs); + + try { + let response; + let text; + try { + response = await fetchImpl(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + signal: controller.signal, + }); + text = await response.text(); + } catch (error) { + throw classifyNetworkError(error); + } + return interpret(response, text); + } finally { + clearTimeout(timer); + } + } + + async function call(method, params = []) { + for (let attempt = 0; ; attempt += 1) { + try { + return await once(method, params); + } catch (error) { + const isLast = attempt >= settings.attempts - 1; + if (!(error instanceof RpcError) || !error.retryable || isLast) throw error; + + let delayMs = backoffDelay(attempt, settings, random); + if (error.retryAfterMs !== null) { + delayMs = Math.min(settings.maxDelayMs, Math.max(delayMs, error.retryAfterMs)); + } + onRetry({ attempt: attempt + 1, delayMs, error }); + await sleep(delayMs); + } + } + } + + return { call }; +} + +module.exports = { + DEFAULT_OPTIONS, + RpcError, + backoffDelay, + createRpcClient, + parseRetryAfter, +}; \ No newline at end of file diff --git a/tests/cli.test.js b/tests/cli.test.js index bf7144c..dfdaa09 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -7,13 +7,14 @@ const { run } = require('../src/cli'); const PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; -function capture() { +function capture(env = {}) { const out = []; const err = []; return { io: { stdout: { write: (text) => out.push(text) }, stderr: { write: (text) => err.push(text) }, + env, }, stdout: () => out.join(''), stderr: () => err.join(''), @@ -24,6 +25,7 @@ test('--help prints usage and exits 0', async () => { const c = capture(); assert.equal(await run(['--help'], c.io), 0); assert.match(c.stdout(), /Usage:/); + assert.match(c.stdout(), /SOL_PROGRAM_INFO_RPC/); assert.equal(c.stderr(), ''); }); @@ -57,8 +59,24 @@ test('an invalid program ID exits 1 and says why', async () => { assert.match(c.stderr(), /Not a valid program ID \(not-a-program\)/); }); -test('a valid program ID exits 0', async () => { +test('a valid program ID exits 0 and names the public RPC host', async () => { const c = capture(); assert.equal(await run([PROGRAM], c.io), 0); assert.match(c.stdout(), /is a valid program ID/); + assert.match(c.stderr(), /Using api\.mainnet-beta\.solana\.com\./); +}); + +test('your own endpoint shows only its hostname, never the key', async () => { + const c = capture({ SOL_PROGRAM_INFO_RPC: 'https://rpc.example.com/v2/secret123?key=secret123' }); + assert.equal(await run([PROGRAM], c.io), 0); + assert.match(c.stderr(), /Using rpc\.example\.com\./); + assert.equal(c.stderr().includes('secret123'), false); + assert.equal(c.stdout().includes('secret123'), false); +}); + +test('a bad endpoint setting exits 1 without echoing the value', async () => { + const c = capture({ SOL_PROGRAM_INFO_RPC: 'wss://rpc.example.com/?key=secret123' }); + assert.equal(await run([PROGRAM], c.io), 1); + assert.match(c.stderr(), /SOL_PROGRAM_INFO_RPC must start with http/); + assert.equal(c.stderr().includes('secret123'), false); }); \ No newline at end of file diff --git a/tests/endpoint.test.js b/tests/endpoint.test.js new file mode 100644 index 0000000..3e66a7d --- /dev/null +++ b/tests/endpoint.test.js @@ -0,0 +1,82 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + EndpointError, + RPC_ENV, + hostLabel, + rateLimitHint, + resolveEndpoint, +} = require('../src/endpoint'); + +const KEYED = 'https://rpc.example.com/v2/secret123?api-key=secret123'; + +test('no setting falls back to the public endpoint', () => { + const endpoint = resolveEndpoint({}); + assert.equal(endpoint.rpcUrl, 'https://api.mainnet-beta.solana.com'); + assert.equal(endpoint.host, 'api.mainnet-beta.solana.com'); + assert.equal(endpoint.isPublic, true); +}); + +test('an empty or blank setting counts as not set', () => { + assert.equal(resolveEndpoint({ [RPC_ENV]: '' }).isPublic, true); + assert.equal(resolveEndpoint({ [RPC_ENV]: ' ' }).isPublic, true); +}); + +test('your own endpoint is used and only its host is exposed', () => { + const endpoint = resolveEndpoint({ [RPC_ENV]: KEYED }); + assert.equal(endpoint.rpcUrl, KEYED); + assert.equal(endpoint.host, 'rpc.example.com'); + assert.equal(endpoint.isPublic, false); +}); + +test('http:// is allowed, for a local validator', () => { + const endpoint = resolveEndpoint({ [RPC_ENV]: 'http://localhost:8899' }); + assert.equal(endpoint.host, 'localhost'); + assert.equal(endpoint.isPublic, false); +}); + +test('setting the public URL yourself still counts as public', () => { + const endpoint = resolveEndpoint({ [RPC_ENV]: 'https://api.devnet.solana.com' }); + assert.equal(endpoint.isPublic, true); +}); + +test('a WebSocket URL is rejected without echoing the value', () => { + assert.throws( + () => resolveEndpoint({ [RPC_ENV]: 'wss://rpc.example.com/?api-key=secret123' }), + (error) => { + assert.ok(error instanceof EndpointError); + assert.match(error.message, /must start with http:\/\/ or https:\/\//); + assert.equal(error.message.includes('secret123'), false); + return true; + } + ); +}); + +test('something that is not a URL is rejected without echoing the value', () => { + assert.throws( + () => resolveEndpoint({ [RPC_ENV]: 'not a url secret123' }), + (error) => { + assert.ok(error instanceof EndpointError); + assert.match(error.message, /not a valid URL/); + assert.equal(error.message.includes('secret123'), false); + return true; + } + ); +}); + +test('hostLabel keeps only the hostname', () => { + assert.equal(hostLabel(KEYED), 'rpc.example.com'); + assert.equal(hostLabel('not a url'), 'the endpoint'); +}); + +test('the rate-limit hint depends on whether you use the public RPC', () => { + const publicHint = rateLimitHint({ isPublic: true }); + assert.match(publicHint, /public RPC/); + assert.match(publicHint, new RegExp(RPC_ENV)); + + const ownHint = rateLimitHint({ isPublic: false }); + assert.match(ownHint, /plan's limits/); + assert.equal(ownHint.includes(RPC_ENV), false); +}); \ No newline at end of file diff --git a/tests/helpers/mock-rpc.js b/tests/helpers/mock-rpc.js new file mode 100644 index 0000000..9631812 --- /dev/null +++ b/tests/helpers/mock-rpc.js @@ -0,0 +1,76 @@ +'use strict'; + +/** Build a fetch-like response. `body` is JSON-encoded unless `text` is given. */ +function makeResponse({ status = 200, body, text, headers = {} }) { + const lower = {}; + for (const [key, value] of Object.entries(headers)) lower[key.toLowerCase()] = String(value); + + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (name) => { + const value = lower[String(name).toLowerCase()]; + return value === undefined ? null : value; + }, + }, + text: async () => (text !== undefined ? text : JSON.stringify(body === undefined ? {} : body)), + }; +} + +/** + * A fake fetch for tests. `steps` are answered in order, and the last one + * repeats when they run out. A step is an object or a function + * ({ url, init, request }) => object, with these keys: + * status, body, text, headers a normal response + * error throw this instead of answering + * hang never answer; reject when the request is aborted + * Every request is recorded in fetch.calls as { url, init, request }. + */ +function createMockFetch(steps) { + const calls = []; + let index = 0; + + async function mockFetch(url, init) { + const request = init && init.body ? JSON.parse(init.body) : null; + calls.push({ url, init, request }); + + const raw = steps[Math.min(index, steps.length - 1)]; + index += 1; + const step = typeof raw === 'function' ? raw({ url, init, request }) : raw; + + if (step.error) throw step.error; + + if (step.hang) { + return new Promise((resolve, reject) => { + init.signal.addEventListener('abort', () => { + const error = new Error('aborted'); + error.name = 'AbortError'; + reject(error); + }); + }); + } + + return makeResponse(step); + } + + mockFetch.calls = calls; + return mockFetch; +} + +/** A successful JSON-RPC answer. */ +function ok(result) { + return { body: { jsonrpc: '2.0', id: 1, result } }; +} + +/** A JSON-RPC error answer (HTTP 200 with an error object). */ +function rpcFail(code, message) { + return { body: { jsonrpc: '2.0', id: 1, error: { code, message } } }; +} + +/** A plain HTTP failure such as 429 or 503. */ +function http(status, extra = {}) { + return { status, body: {}, ...extra }; +} + +module.exports = { createMockFetch, http, makeResponse, ok, rpcFail }; \ No newline at end of file diff --git a/tests/rpc.test.js b/tests/rpc.test.js new file mode 100644 index 0000000..6718fc4 --- /dev/null +++ b/tests/rpc.test.js @@ -0,0 +1,220 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { RpcError, backoffDelay, createRpcClient, parseRetryAfter } = require('../src/rpc'); +const { createMockFetch, http, ok, rpcFail } = require('./helpers/mock-rpc'); + +const URL_WITH_KEY = 'https://rpc.example.com/?api-key=secret123'; + +function setup(steps, extra = {}) { + const fetch = createMockFetch(steps); + const sleeps = []; + const retries = []; + const rpc = createRpcClient({ + url: URL_WITH_KEY, + fetch, + sleep: async (ms) => { + sleeps.push(ms); + }, + random: () => 0.5, + onRetry: (info) => retries.push(info), + ...extra, + }); + return { rpc, fetch, sleeps, retries }; +} + +async function failure(promise) { + try { + await promise; + } catch (error) { + return error; + } + return assert.fail('expected the call to reject'); +} + +test('returns the result and sends a proper JSON-RPC request', async () => { + const { rpc, fetch } = setup([ok({ value: null })]); + + const result = await rpc.call('getAccountInfo', ['abc', { encoding: 'jsonParsed' }]); + + assert.deepEqual(result, { value: null }); + assert.equal(fetch.calls.length, 1); + const call = fetch.calls[0]; + assert.equal(call.url, URL_WITH_KEY); + assert.equal(call.init.method, 'POST'); + assert.equal(call.request.jsonrpc, '2.0'); + assert.equal(call.request.method, 'getAccountInfo'); + assert.deepEqual(call.request.params, ['abc', { encoding: 'jsonParsed' }]); +}); + +test('params default to an empty list', async () => { + const { rpc, fetch } = setup([ok(1)]); + await rpc.call('getVersion'); + assert.deepEqual(fetch.calls[0].request.params, []); +}); + +test('a 429 is retried after a backoff and then succeeds', async () => { + const { rpc, sleeps, retries } = setup([http(429), ok('hello')]); + + assert.equal(await rpc.call('getVersion'), 'hello'); + + assert.deepEqual(sleeps, [500]); + assert.equal(retries.length, 1); + assert.equal(retries[0].attempt, 1); + assert.equal(retries[0].delayMs, 500); + assert.equal(retries[0].error.kind, 'rate-limited'); +}); + +test('a Retry-After header is respected', async () => { + const { rpc, sleeps } = setup([http(429, { headers: { 'Retry-After': '2' } }), ok(1)]); + await rpc.call('getVersion'); + assert.deepEqual(sleeps, [2000]); +}); + +test('a huge Retry-After is capped at maxDelayMs', async () => { + const { rpc, sleeps } = setup([http(429, { headers: { 'retry-after': '120' } }), ok(1)]); + await rpc.call('getVersion'); + assert.deepEqual(sleeps, [8000]); +}); + +test('gives up after the allowed attempts on a steady 429', async () => { + const { rpc, fetch, retries } = setup([http(429)], { attempts: 3 }); + + const error = await failure(rpc.call('getVersion')); + + assert.ok(error instanceof RpcError); + assert.equal(error.kind, 'rate-limited'); + assert.equal(error.rateLimited, true); + assert.match(error.message, /rate limiting/); + assert.equal(fetch.calls.length, 3); + assert.equal(retries.length, 2); +}); + +test('401 and 403 stop at once and mention the API key', async () => { + for (const status of [401, 403]) { + const { rpc, fetch, sleeps } = setup([http(status)]); + + const error = await failure(rpc.call('getVersion')); + + assert.equal(error.kind, 'auth'); + assert.equal(error.retryable, false); + assert.match(error.message, new RegExp(`HTTP ${status}`)); + assert.match(error.message, /API key/); + assert.equal(fetch.calls.length, 1); + assert.equal(sleeps.length, 0); + } +}); + +test('404 stops at once and says to check the URL', async () => { + const { rpc, fetch } = setup([http(404)]); + + const error = await failure(rpc.call('getVersion')); + + assert.equal(error.kind, 'not-found'); + assert.match(error.message, /check the URL/); + assert.equal(fetch.calls.length, 1); +}); + +test('a 503 is retried', async () => { + const { rpc, fetch } = setup([http(503), ok('up')]); + assert.equal(await rpc.call('getVersion'), 'up'); + assert.equal(fetch.calls.length, 2); +}); + +test('other HTTP statuses are not retried', async () => { + const { rpc, fetch } = setup([http(400)]); + + const error = await failure(rpc.call('getVersion')); + + assert.equal(error.kind, 'bad-response'); + assert.match(error.message, /HTTP 400/); + assert.equal(fetch.calls.length, 1); +}); + +test('a network error is retried and then reported in plain English', async () => { + const failing = Object.assign(new TypeError(`fetch failed ${URL_WITH_KEY}`), { + cause: { code: 'ENOTFOUND' }, + }); + const { rpc, fetch } = setup([{ error: failing }], { attempts: 2 }); + + const error = await failure(rpc.call('getVersion')); + + assert.equal(error.kind, 'network'); + assert.match(error.message, /DNS/); + assert.equal(fetch.calls.length, 2); +}); + +test('a request that never answers times out', async () => { + const { rpc } = setup([{ hang: true }], { attempts: 1, timeoutMs: 10 }); + + const error = await failure(rpc.call('getVersion')); + + assert.equal(error.kind, 'timeout'); + assert.match(error.message, /did not answer in time/); +}); + +test('a JSON-RPC error is not retried and keeps its message', async () => { + const { rpc, fetch } = setup([rpcFail(-32602, 'Invalid param: bad address')]); + + const error = await failure(rpc.call('getAccountInfo', ['x'])); + + assert.equal(error.kind, 'rpc'); + assert.match(error.message, /Invalid param: bad address/); + assert.equal(fetch.calls.length, 1); +}); + +test('a JSON-RPC rate-limit error is retried', async () => { + const { rpc, retries } = setup([rpcFail(429, 'Too many requests'), ok('x')]); + + assert.equal(await rpc.call('getVersion'), 'x'); + assert.equal(retries[0].error.kind, 'rate-limited'); +}); + +test('a body that is not JSON is a bad response, not retried', async () => { + const { rpc, fetch } = setup([{ text: 'oops' }]); + + const error = await failure(rpc.call('getVersion')); + + assert.equal(error.kind, 'bad-response'); + assert.match(error.message, /did not return JSON/); + assert.equal(fetch.calls.length, 1); +}); + +test('no error message ever contains the endpoint URL or key', async () => { + const stepsList = [ + [http(429)], + [http(401)], + [http(404)], + [http(503)], + [http(400)], + [{ error: Object.assign(new TypeError(`fetch failed ${URL_WITH_KEY}`), { cause: { code: 'ECONNREFUSED' } }) }], + [{ error: new Error(URL_WITH_KEY) }], + [{ text: URL_WITH_KEY }], + ]; + + for (const steps of stepsList) { + const { rpc } = setup(steps, { attempts: 1 }); + const error = await failure(rpc.call('getVersion')); + assert.equal(error.message.includes('secret123'), false, error.message); + assert.equal(error.message.includes('rpc.example.com'), false, error.message); + } +}); + +test('parseRetryAfter reads seconds, dates and garbage', () => { + assert.equal(parseRetryAfter('3'), 3000); + assert.equal(parseRetryAfter(null), null); + assert.equal(parseRetryAfter('soon'), null); + assert.equal( + parseRetryAfter('Wed, 21 Oct 2015 07:28:10 GMT', Date.parse('Wed, 21 Oct 2015 07:28:00 GMT')), + 10000 + ); +}); + +test('backoffDelay grows, jitters within 25% and is capped', () => { + assert.equal(backoffDelay(0, {}, () => 0.5), 500); + assert.equal(backoffDelay(1, {}, () => 0.5), 1000); + assert.equal(backoffDelay(0, {}, () => 0), 375); + assert.equal(backoffDelay(0, {}, () => 1), 625); + assert.equal(backoffDelay(10, {}, () => 1), 8000); +}); \ No newline at end of file