src/transport/exchange.test.ts
v0.3.0 · 5.3 KB
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);
});
});