Skip to content
markpaper

src/transport/exchange.test.ts

v0.3.0 · 5.3 KB

Download file
import { afterEach, describe, expect, it } from 'vitest';
import { limitExchange } from './exchange.js';
import { createWeightLimiter, resetSharedWeightLimiters, type AcquireOptions, type WeightLimiter } from './limiter.js';

afterEach(() => {
  resetSharedWeightLimiters();
});

const FAST = { baseDelayMs: 1, jitter: 0 };
const limiter = () => createWeightLimiter({ weightPerMinute: 600, burstCapacity: 80, maxConcurrent: 3 });

/** SDK-shaped transport error: `HttpRequestError` with `response.status`. */
function sdkHttpError(status: number): Error {
  return Object.assign(new Error(`${status}`), { name: 'HttpRequestError', response: { status } });
}

function failingThenOk(errors: unknown[]) {
  let calls = 0;
  const fn = async () => {
    const e = errors[calls];
    calls++;
    if (e !== undefined) throw e;
    return 'placed';
  };
  return { fn, calls: () => calls };
}

describe('limitExchange', () => {
  it('non-idempotent actions retry on 429 only', async () => {
    const a = failingThenOk([sdkHttpError(429), sdkHttpError(429)]);
    await expect(limitExchange(a.fn, { idempotent: false, retry: FAST, limiter: limiter() })).resolves.toBe('placed');
    expect(a.calls()).toBe(3);

    // 5xx after an OPEN: the order may already rest; a blind retry would double the position.
    const b = failingThenOk([sdkHttpError(502)]);
    await expect(limitExchange(b.fn, { idempotent: false, retry: FAST, limiter: limiter() })).rejects.toThrow('502');
    expect(b.calls()).toBe(1);

    const c = failingThenOk([Object.assign(new Error('Request timed out after 10000 ms'), { name: 'HttpRequestError' })]);
    await expect(limitExchange(c.fn, { idempotent: false, retry: FAST, limiter: limiter() })).rejects.toThrow();
    expect(c.calls()).toBe(1);
  });

  it('idempotent actions (reduce-only close, updateLeverage) retry on 5xx and timeouts too', async () => {
    const a = failingThenOk([sdkHttpError(503), new Error('socket hang up'), sdkHttpError(429)]);
    await expect(limitExchange(a.fn, { idempotent: true, retry: FAST, limiter: limiter() })).resolves.toBe('placed');
    expect(a.calls()).toBe(4);
  });

  it('exchange rejections (4xx, ApiRequestError) are never retried', async () => {
    const api = Object.assign(new Error('Insufficient margin'), { name: 'ApiRequestError', response: { status: 'err', response: 'x' } });
    const a = failingThenOk([api]);
    await expect(limitExchange(a.fn, { idempotent: true, retry: FAST, limiter: limiter() })).rejects.toThrow('Insufficient margin');
    expect(a.calls()).toBe(1);
  });

  it('requires an explicit limiter', () => {
    expect(() =>
      limitExchange(async () => 1, { idempotent: false } as never),
    ).toThrow('limitExchange requires an explicit limiter');
  });

  it('queues closes as urgent and other actions as high', async () => {
    const seen: Array<AcquireOptions | undefined> = [];
    const real = limiter();
    const spy: WeightLimiter = {
      ...real,
      schedule: (weight, fn, opts) => {
        seen.push(opts);
        return real.schedule(weight, fn, opts);
      },
    };
    await limitExchange(async () => 1, { idempotent: true, urgent: true, limiter: spy });
    await limitExchange(async () => 1, { idempotent: false, limiter: spy, weight: 3 });
    expect(seen.map((o) => o?.priority)).toEqual(['urgent', 'high']);
    expect(real.stats().totalWeight).toBe(5);
  });

  it('retry: false makes a single attempt even on 429', async () => {
    const a = failingThenOk([sdkHttpError(429)]);
    await expect(limitExchange(a.fn, { idempotent: false, retry: false, limiter: limiter() })).rejects.toThrow('429');
    expect(a.calls()).toBe(1);
  });
});

describe('limitExchange edge cases', () => {
  it('a partially accepted batch (ApiRequestError mentioning asset=429) is not retried as a rate limit', async () => {
    const { ApiRequestError } = await import('@nktkas/hyperliquid/api/exchange');
    const partial = new ApiRequestError(
      { status: 'ok', response: { type: 'order', data: { statuses: [{ resting: { oid: 7 } }, { error: 'Insufficient margin. asset=429' }] } } },
      'order 1: Insufficient margin. asset=429',
    );
    const a = failingThenOk([partial]);
    await expect(limitExchange(a.fn, { idempotent: false, retry: FAST, limiter: limiter() })).rejects.toBe(partial);
    expect(a.calls()).toBe(1);
  });

  it('reserves max(2, 1 + floor(nOrders/40)) so large batches are not under-counted', async () => {
    const cases: Array<[number | undefined, number]> = [[undefined, 2], [1, 2], [39, 2], [80, 3], [200, 6]];
    for (const [nOrders, expected] of cases) {
      const configured = limiter();
      await limitExchange(async () => 1, { idempotent: false, limiter: configured, ...(nOrders === undefined ? {} : { nOrders }) });
      expect(configured.stats().totalWeight).toBe(expected);
    }
    const configured = limiter();
    await limitExchange(async () => 1, { idempotent: false, limiter: configured, nOrders: 200, weight: 1 });
    expect(configured.stats().totalWeight).toBe(1);
  });

  it('an SDK 400 whose body mentions 429 is not retried for an opening order', async () => {
    const e = Object.assign(new Error('400 Bad Request - px 429.5 invalid'), { name: 'HttpRequestError', response: { status: 400 } });
    const a = failingThenOk([e]);
    await expect(limitExchange(a.fn, { idempotent: false, retry: FAST, limiter: limiter() })).rejects.toBe(e);
    expect(a.calls()).toBe(1);
  });
});
All files