Skip to content
markpaper

src/transport/info-client.test.ts

v0.3.0 · 19.9 KB

Download file
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);
  });
});
All files