src/transport/info-client.test.ts
v0.3.0 · 19.9 KB
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HlHttpError, HlNetworkError, HlResponseParseError, HlTimeoutError } from './errors.js';
import { createInfoClient, normalizeInfoBody, resolveInfoUrl, type FetchLike, type InfoAttemptEvent } from './info-client.js';
import { createWeightLimiter, resetSharedWeightLimiters } from './limiter.js';
import type { InfoRequester } from './types.js';
const ADDR_MIXED = '0x00000000000000000000000000000000000000Ab'; // privacy-allow: placeholder address
const ADDR_LOWER = '0x00000000000000000000000000000000000000ab'; // privacy-allow: placeholder address
const FAST_RETRY = { baseDelayMs: 1, jitter: 0 };
afterEach(() => {
resetSharedWeightLimiters();
});
interface Captured {
url: string;
init: RequestInit;
}
function stubFetch(responses: Array<Response | Error | ((init: RequestInit) => Promise<Response>)>) {
const calls: Captured[] = [];
let i = 0;
const fn: FetchLike = async (url, init) => {
calls.push({ url, init });
const next = responses[Math.min(i, responses.length - 1)];
i++;
if (next instanceof Error) throw next;
if (typeof next === 'function') return next(init);
return (next as Response).clone();
};
return { fn, calls };
}
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const bigLimiter = () =>
createWeightLimiter({ weightPerMinute: 1_000_000, burstCapacity: 1000, maxConcurrent: 4 });
describe('resolveInfoUrl', () => {
it('defaults to mainnet and supports testnet', () => {
expect(resolveInfoUrl()).toBe('https://api.hyperliquid.xyz/info');
expect(resolveInfoUrl({ network: 'testnet' })).toBe('https://api.hyperliquid-testnet.xyz/info');
});
it('appends /info to a base URL once', () => {
expect(resolveInfoUrl({ baseUrl: 'https://proxy.example.com/' })).toBe('https://proxy.example.com/info');
expect(resolveInfoUrl({ baseUrl: 'https://proxy.example.com/hl/info' })).toBe('https://proxy.example.com/hl/info');
});
it('refuses to mix mainnet and testnet official hosts', () => {
expect(() => resolveInfoUrl({ network: 'mainnet', baseUrl: 'https://api.hyperliquid-testnet.xyz' })).toThrow(/mix mainnet and testnet/);
expect(() => resolveInfoUrl({ network: 'testnet', baseUrl: 'https://api.hyperliquid.xyz/info' })).toThrow(/mix mainnet and testnet/);
expect(resolveInfoUrl({ network: 'testnet', baseUrl: 'https://proxy.example.com' })).toBe('https://proxy.example.com/info');
});
it('rejects invalid URLs', () => {
expect(() => resolveInfoUrl({ baseUrl: 'not a url' })).toThrow(/invalid/);
expect(() => resolveInfoUrl({ baseUrl: 'ftp://example.com' })).toThrow(/invalid/);
});
});
describe('normalizeInfoBody', () => {
it('omits an empty main-dex key and lower-cases addresses without mutating the input', () => {
const body = { type: 'clearinghouseState', user: ADDR_MIXED, dex: '' };
const out = normalizeInfoBody(body);
expect(out).toEqual({ type: 'clearinghouseState', user: ADDR_LOWER });
expect('dex' in out).toBe(false);
expect(body.user).toBe(ADDR_MIXED);
expect(normalizeInfoBody({ type: 'allMids', dex: 'xyz' })).toEqual({ type: 'allMids', dex: 'xyz' });
expect(normalizeInfoBody({ type: 'allMids', dex: null })).toEqual({ type: 'allMids' });
});
it('leaves non-address strings and nested objects alone; lower-casing can be disabled', () => {
const req = { coin: 'BTC', interval: '1h', startTime: 1 };
expect(normalizeInfoBody({ type: 'candleSnapshot', req })).toEqual({ type: 'candleSnapshot', req });
expect(normalizeInfoBody({ type: 'x', coin: 'xyz:TSLA' })).toEqual({ type: 'x', coin: 'xyz:TSLA' });
expect(normalizeInfoBody({ type: 'x', user: ADDR_MIXED }, false)).toEqual({ type: 'x', user: ADDR_MIXED });
});
});
describe('createInfoClient', () => {
it('POSTs JSON to /info and resolves with the parsed body', async () => {
const { fn, calls } = stubFetch([json({ universe: [] })]);
const info = createInfoClient({ fetch: fn, limiter: bigLimiter() });
await expect(info({ type: 'meta' })).resolves.toEqual({ universe: [] });
expect(calls).toHaveLength(1);
const call = calls[0] as Captured;
expect(call.url).toBe('https://api.hyperliquid.xyz/info');
expect(call.init.method).toBe('POST');
expect((call.init.headers as Record<string, string>)['content-type']).toBe('application/json');
expect(JSON.parse(call.init.body as string)).toEqual({ type: 'meta' });
expect(call.init.signal).toBeInstanceOf(AbortSignal);
expect(info.url).toBe('https://api.hyperliquid.xyz/info');
});
it('is assignable to the shared InfoRequester contract', async () => {
const { fn } = stubFetch([json([1, 2])]);
const requester: InfoRequester = createInfoClient({ fetch: fn, limiter: null });
await expect(requester<number[]>({ type: 'allMids' })).resolves.toEqual([1, 2]);
});
it('sends normalized bodies', async () => {
const { fn, calls } = stubFetch([json({})]);
const info = createInfoClient({ fetch: fn, limiter: null, network: 'testnet' });
await info({ type: 'frontendOpenOrders', user: ADDR_MIXED, dex: '' });
expect(calls[0]?.url).toBe('https://api.hyperliquid-testnet.xyz/info');
expect(JSON.parse(calls[0]?.init.body as string)).toEqual({ type: 'frontendOpenOrders', user: ADDR_LOWER });
});
it('rejects a body without a type', async () => {
const info = createInfoClient({ fetch: stubFetch([json({})]).fn, limiter: null });
await expect(info({ type: '' })).rejects.toThrow(TypeError);
});
it('pays the weight derived from type into the limiter, or the explicit override', async () => {
const limiter = bigLimiter();
const { fn } = stubFetch([json({})]);
const info = createInfoClient({ fetch: fn, limiter });
await info({ type: 'clearinghouseState', user: ADDR_LOWER });
expect(limiter.stats().totalWeight).toBe(2);
await info({ type: 'frontendOpenOrders', user: ADDR_LOWER });
expect(limiter.stats().totalWeight).toBe(22);
await info({ type: 'userRole', user: ADDR_LOWER });
expect(limiter.stats().totalWeight).toBe(82);
await info({ type: 'meta' }, { weight: 5 });
expect(limiter.stats().totalWeight).toBe(87);
});
it('requires an explicit limiter or explicit external throttling', () => {
expect(() => (createInfoClient as unknown as () => unknown)()).toThrow(
'createInfoClient requires an explicit limiter',
);
});
it('queues by priority in the limiter', async () => {
const limiter = createWeightLimiter({ weightPerMinute: 60_000, burstCapacity: 20, maxConcurrent: 3 });
const order: string[] = [];
const fetchFn: FetchLike = async (_url, init) => {
order.push(JSON.parse(init.body as string).label as string);
return json({});
};
const info = createInfoClient({ fetch: fetchFn, limiter });
await limiter.acquire(20);
await Promise.all([
info({ type: 'meta', label: 'normal' }),
info({ type: 'meta', label: 'urgent' }, { priority: 'urgent' }),
info({ type: 'meta', label: 'high' }, { priority: 'high' }),
]);
expect(order).toEqual(['urgent', 'high', 'normal']);
});
it('retries 429 and succeeds; every attempt pays weight again', async () => {
const limiter = bigLimiter();
const { fn, calls } = stubFetch([json({ e: 1 }, 429), json({ e: 1 }, 429), json({ ok: true })]);
const info = createInfoClient({ fetch: fn, limiter, retry: FAST_RETRY });
await expect(info({ type: 'openOrders', user: ADDR_LOWER })).resolves.toEqual({ ok: true });
expect(calls).toHaveLength(3);
expect(limiter.stats().totalWeight).toBe(60);
});
it('gives up after 4 retries with HlHttpError carrying status and body', async () => {
const { fn, calls } = stubFetch([new Response('Too Many Requests', { status: 429 })]);
const info = createInfoClient({ fetch: fn, limiter: null, retry: FAST_RETRY });
const err = await info({ type: 'meta' }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HlHttpError);
expect((err as HlHttpError).status).toBe(429);
expect((err as HlHttpError).bodyText).toBe('Too Many Requests');
expect((err as HlHttpError).requestType).toBe('meta');
expect(calls).toHaveLength(5);
});
it('retries 5xx (idempotent info) but not 4xx such as 422 unknown dex', async () => {
const s1 = stubFetch([new Response('bad gateway', { status: 502 }), json([])]);
await expect(createInfoClient({ fetch: s1.fn, limiter: null, retry: FAST_RETRY })({ type: 'allMids' })).resolves.toEqual([]);
expect(s1.calls).toHaveLength(2);
const s2 = stubFetch([new Response('unknown dex', { status: 422 })]);
const err = await createInfoClient({ fetch: s2.fn, limiter: null, retry: FAST_RETRY })({ type: 'allMids', dex: 'nope' }).catch((e: unknown) => e);
expect((err as HlHttpError).status).toBe(422);
expect(s2.calls).toHaveLength(1);
});
it('wraps network failures into HlNetworkError and retries them', async () => {
const { fn, calls } = stubFetch([new TypeError('fetch failed'), json({ ok: 1 })]);
await expect(createInfoClient({ fetch: fn, limiter: null, retry: FAST_RETRY })({ type: 'meta' })).resolves.toEqual({ ok: 1 });
expect(calls).toHaveLength(2);
const s2 = stubFetch([new TypeError('fetch failed')]);
const err = await createInfoClient({ fetch: s2.fn, limiter: null, retry: false })({ type: 'meta' }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HlNetworkError);
expect((err as HlNetworkError).cause).toBeInstanceOf(TypeError);
});
it('times out a hung request with HlTimeoutError (per attempt) and retries', async () => {
const hanging = (init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(init.signal?.reason));
});
const { fn, calls } = stubFetch([hanging, json({ ok: true })]);
const info = createInfoClient({ fetch: fn, limiter: null, timeoutMs: 20, retry: FAST_RETRY });
await expect(info({ type: 'meta' })).resolves.toEqual({ ok: true });
expect(calls).toHaveLength(2);
const s2 = stubFetch([hanging]);
const err = await createInfoClient({ fetch: s2.fn, limiter: null, retry: false })({ type: 'meta' }, { timeoutMs: 15 }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HlTimeoutError);
expect((err as HlTimeoutError).timeoutMs).toBe(15);
});
it('treats an unparsable 200 body as transient (HTML from a balancer)', async () => {
const { fn, calls } = stubFetch([new Response('<html>502</html>', { status: 200 }), json({ ok: 2 })]);
await expect(createInfoClient({ fetch: fn, limiter: null, retry: FAST_RETRY })({ type: 'meta' })).resolves.toEqual({ ok: 2 });
expect(calls).toHaveLength(2);
const s2 = stubFetch([new Response('{"cut', { status: 200 })]);
const err = await createInfoClient({ fetch: s2.fn, limiter: null, retry: false })({ type: 'meta' }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HlResponseParseError);
expect((err as HlResponseParseError).cause).toBeInstanceOf(SyntaxError);
});
it('a caller abort is not retried and rejects with the caller reason', async () => {
const ctl = new AbortController();
const hanging = (init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(init.signal?.reason));
});
const { fn, calls } = stubFetch([hanging]);
const info = createInfoClient({ fetch: fn, limiter: null, retry: FAST_RETRY });
const p = info({ type: 'meta' }, { signal: ctl.signal });
await new Promise((r) => setTimeout(r, 5));
ctl.abort(new Error('shutdown'));
await expect(p).rejects.toThrow('shutdown');
await new Promise((r) => setTimeout(r, 10));
expect(calls).toHaveLength(1);
});
it('a caller abort while queued in the limiter never reaches fetch', async () => {
const limiter = createWeightLimiter({ weightPerMinute: 1, burstCapacity: 20, maxConcurrent: 3 });
await limiter.acquire(20);
const { fn, calls } = stubFetch([json({})]);
const ctl = new AbortController();
const p = createInfoClient({ fetch: fn, limiter })({ type: 'meta' }, { signal: ctl.signal });
ctl.abort(new Error('gave up'));
await expect(p).rejects.toThrow('gave up');
expect(calls).toHaveLength(0);
});
it('does not retry a weight above the bucket capacity', async () => {
const limiter = createWeightLimiter({ weightPerMinute: 600, burstCapacity: 50, maxConcurrent: 3 });
const { fn, calls } = stubFetch([json({})]);
await expect(createInfoClient({ fetch: fn, limiter, retry: FAST_RETRY })({ type: 'userRole', user: ADDR_LOWER })).rejects.toThrow(/chunk the request/);
expect(calls).toHaveLength(0);
});
it('charges the per-item surcharge after history responses (experimental), unless disabled', async () => {
const fills = Array.from({ length: 41 }, (_, i) => ({ tid: i }));
const l1 = bigLimiter();
await createInfoClient({ fetch: stubFetch([json(fills)]).fn, limiter: l1 })({ type: 'userFills', user: ADDR_LOWER });
expect(l1.stats().totalWeight).toBe(20 + 3);
const l2 = bigLimiter();
await createInfoClient({ fetch: stubFetch([json(fills)]).fn, limiter: l2, chargeResponseWeight: false })({ type: 'userFills', user: ADDR_LOWER });
expect(l2.stats().totalWeight).toBe(20);
});
it('reports every attempt to onAttempt with label, weight and status', async () => {
const events: InfoAttemptEvent[] = [];
const { fn } = stubFetch([new Response('x', { status: 503 }), json([{ a: 1 }])]);
const info = createInfoClient({ fetch: fn, limiter: bigLimiter(), retry: FAST_RETRY, onAttempt: (e) => events.push(e) });
await info({ type: 'userFills', user: ADDR_LOWER }, { label: 'mon:userFills' });
expect(events.map((e) => [e.attempt, e.ok, e.status, e.label, e.weight, e.extraWeight])).toEqual([
[0, false, 503, 'mon:userFills', 20, 0],
[1, true, 200, 'mon:userFills', 20, 1],
]);
expect(events[0]?.error).toBeInstanceOf(HlHttpError);
});
it('supports a custom JSON parser for 64-bit ids', async () => {
const { fn } = stubFetch([new Response('{"oid":12345678901234567890}', { status: 200 })]);
const parseJson = (text: string) => JSON.parse(text.replace(/"oid":(\d+)/g, '"oid":"$1"'));
const info = createInfoClient({ fetch: fn, limiter: null, parseJson });
await expect(info({ type: 'orderStatus' })).resolves.toEqual({ oid: '12345678901234567890' });
});
it('retry: false makes a single attempt', async () => {
const { fn, calls } = stubFetch([new Response('x', { status: 500 })]);
await expect(createInfoClient({ fetch: fn, limiter: null, retry: false })({ type: 'meta' })).rejects.toBeInstanceOf(HlHttpError);
expect(calls).toHaveLength(1);
});
it('uses the global fetch when none is provided', async () => {
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({ g: 1 }));
try {
await expect(createInfoClient({ limiter: null })({ type: 'meta' })).resolves.toEqual({ g: 1 });
expect(spy).toHaveBeenCalledOnce();
} finally {
spy.mockRestore();
}
});
});
describe('createInfoClient on live-shaped responses', () => {
it('does not retry HL "500 null" (unknown dex, bare HIP-3 coin + dex): one attempt, one weight', async () => {
// Live 2026-09: {type:'meta', dex:'<unknown>'} and candleSnapshot {coin:'TSLA'} + dex:'xyz' both answer 500 "null".
const limiter = bigLimiter();
const { fn, calls } = stubFetch([new Response('null', { status: 500, headers: { 'content-type': 'application/json' } })]);
const info = createInfoClient({ fetch: fn, limiter, retry: FAST_RETRY });
const err = await info({ type: 'meta', dex: 'nosuchdex' }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(HlHttpError);
expect((err as HlHttpError).invalidRequest).toBe(true);
expect(calls).toHaveLength(1);
expect(limiter.stats().totalWeight).toBe(20);
});
it('a 500 with another body is still retried', async () => {
const { fn, calls } = stubFetch([new Response('internal error', { status: 500 }), json({ universe: [] })]);
await expect(createInfoClient({ fetch: fn, limiter: null, retry: FAST_RETRY })({ type: 'meta' })).resolves.toEqual({ universe: [] });
expect(calls).toHaveLength(2);
});
it('sends HIP-3 candles as a prefixed coin and drops an empty top-level dex', async () => {
const { fn, calls } = stubFetch([json([])]);
const req = { coin: 'xyz:XYZ100', interval: '1h', startTime: 1, endTime: 2 };
await createInfoClient({ fetch: fn, limiter: null })({ type: 'candleSnapshot', req, dex: '' });
expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ type: 'candleSnapshot', req });
});
it('an empty array is a valid answer, not a failure', async () => {
const { fn, calls } = stubFetch([json([])]);
await expect(createInfoClient({ fetch: fn, limiter: null, retry: FAST_RETRY })({ type: 'openOrders', user: ADDR_LOWER })).resolves.toEqual([]);
expect(calls).toHaveLength(1);
});
it('a 5 218-candle page (live 1m x 7d) overdraws the 80 bucket by the surcharge and delays the next call', async () => {
let t = 0;
const limiter = createWeightLimiter({
weightPerMinute: 600,
burstCapacity: 80,
maxConcurrent: 3,
now: () => t,
});
const candles = Array.from({ length: 5218 }, (_, i) => ({ t: i }));
const info = createInfoClient({ fetch: stubFetch([json(candles)]).fn, limiter });
await info({ type: 'candleSnapshot', req: { coin: 'BTC', interval: '1m', startTime: 0, endTime: 1 } });
const s = limiter.stats();
expect(s.totalWeight).toBe(20 + 87);
expect(s.tokens).toBe(80 - 107);
t += 1; // almost no refill: the next request must queue
let admitted = false;
const p = limiter.acquire(2).then(() => (admitted = true));
await new Promise((r) => setTimeout(r, 10));
expect(admitted).toBe(false);
t += 60_000;
limiter.charge(Number.MIN_VALUE); // any state change re-runs the queue with the advanced clock
await p;
expect(admitted).toBe(true);
});
it('HTML 200 from a balancer with a text/html content-type is retried', async () => {
const html = new Response('<html><body>502 Bad Gateway</body></html>', { status: 200, headers: { 'content-type': 'text/html' } });
const { fn, calls } = stubFetch([html, json({ ok: 1 })]);
await expect(createInfoClient({ fetch: fn, limiter: null, retry: FAST_RETRY })({ type: 'allMids' })).resolves.toEqual({ ok: 1 });
expect(calls).toHaveLength(2);
});
});
describe('createInfoClient option validation', () => {
it('rejects invalid timeouts before fetching or paying weight', async () => {
expect(() => createInfoClient({ limiter: null, timeoutMs: -1 })).toThrow(RangeError);
expect(() => createInfoClient({ limiter: null, timeoutMs: Number.NaN })).toThrow(RangeError);
expect(() => createInfoClient({ limiter: null, timeoutMs: 0 })).toThrow(RangeError);
const limiter = bigLimiter();
const { fn, calls } = stubFetch([json({})]);
const info = createInfoClient({ fetch: fn, limiter });
await expect(info({ type: 'meta' }, { timeoutMs: Number.POSITIVE_INFINITY })).rejects.toThrow(RangeError);
expect(calls).toHaveLength(0);
expect(limiter.stats().totalWeight).toBe(0);
});
it('rounds a fractional timeout up instead of crashing inside AbortSignal.timeout', async () => {
const { fn, calls } = stubFetch([json({ ok: 1 })]);
await expect(createInfoClient({ fetch: fn, limiter: null, timeoutMs: 7500.5 })({ type: 'meta' }, { timeoutMs: 99.9 })).resolves.toEqual({ ok: 1 });
expect(calls).toHaveLength(1);
});
it('a throwing onAttempt callback neither fails nor retries a good read', async () => {
const limiter = bigLimiter();
const { fn, calls } = stubFetch([json({ ok: 1 })]);
const onAttempt = () => {
throw new Error('network logger down');
};
const info = createInfoClient({ fetch: fn, limiter, retry: FAST_RETRY, onAttempt });
await expect(info({ type: 'meta' })).resolves.toEqual({ ok: 1 });
expect(calls).toHaveLength(1);
expect(limiter.stats().totalWeight).toBe(20);
});
});