src/errors/sdk-contract.test.ts
v0.3.0 · 5.8 KB
// Contract tests against the real @nktkas/hyperliquid 0.33.3 classes (devDependency). No network:
// ExchangeClient gets a stub transport, HttpTransport gets a stubbed global fetch.
import { ExchangeClient, HttpTransport } from '@nktkas/hyperliquid';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
classifyTransportFailure,
describeTransportFailure,
extractPartialBatch,
interpretExchangeError,
invokeAsync,
recommendRetry,
} from './index.js';
const order = {
a: 0,
b: true,
p: '30000',
s: '0.01',
r: false,
t: { limit: { tif: 'Alo' as const } },
};
function clientReturning(body: unknown): ExchangeClient {
const transport = {
isTestnet: true,
request: async <T>(): Promise<T> => body as T,
};
return new ExchangeClient({ wallet: privateKeyToAccount(generatePrivateKey()), transport });
}
async function caught(p: () => Promise<unknown>): Promise<unknown> {
try {
await p();
} catch (err) {
return err;
}
throw new Error('expected a throw');
}
describe('ExchangeClient errors (stub transport)', () => {
it('partial batch: ApiRequestError still carries placed oids', async () => {
const ex = clientReturning({
status: 'ok',
response: {
type: 'order',
data: {
statuses: [{ resting: { oid: 1 } }, { error: 'Order has invalid price.' }, { filled: { oid: 3, totalSz: '1', avgPx: '2' } }],
},
},
});
const err = await caught(() => ex.order({ orders: [order, order, order], grouping: 'na' }));
expect((err as Error).name).toBe('ApiRequestError');
const batch = extractPartialBatch(err, { expectedCount: 3 });
if (batch?.kind !== 'order') throw new Error('expected an order batch');
expect(batch.placedOids).toEqual([1, 3]);
expect(interpretExchangeError(err).type).toBe('partial');
expect(classifyTransportFailure(err)).toBe('unknown');
expect(recommendRetry(err, { idempotent: false })).toBe('reconcile');
});
it('whole-action rejection: not applied, classified', async () => {
const ex = clientReturning({ status: 'err', response: 'Insufficient margin to place order.' });
const err = await caught(() => ex.order({ orders: [order], grouping: 'na' }));
expect(interpretExchangeError(err)).toMatchObject({ type: 'rejected', kind: 'insufficientMargin' });
expect(classifyTransportFailure(err)).toBe('not-applied');
});
it('ValidationError is thrown synchronously; invokeAsync turns it into a rejection', async () => {
const ex = clientReturning({ status: 'ok', response: { type: 'default' } });
const bad = { orders: [{ ...order, c: '0x1234' as `0x${string}` }], grouping: 'na' as const };
expect(() => ex.order(bad)).toThrow();
const err = await invokeAsync(() => ex.order(bad)).catch((e: unknown) => e);
expect((err as Error).name).toBe('ValidationError');
expect(interpretExchangeError(err).type).toBe('validation');
expect(classifyTransportFailure(err)).toBe('not-applied');
});
});
describe('HttpTransport errors (stubbed fetch)', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
const json = { 'Content-Type': 'application/json' };
async function failWith(fetchImpl: (url: unknown, init?: RequestInit) => Promise<Response>, timeout: number | null = 1000) {
vi.stubGlobal('fetch', fetchImpl);
const transport = new HttpTransport({ timeout });
return caught(() => transport.request('exchange', { action: {} }));
}
it('429 -> not applied, rate limited', async () => {
const err = await failWith(async () => new Response('Too Many Requests', { status: 429, headers: { 'Content-Type': 'text/plain' } }));
expect((err as Error).name).toBe('HttpRequestError');
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'not-applied', rateLimited: true, httpStatus: 429 });
expect(recommendRetry(err, { idempotent: false })).toBe('retry');
});
it('500 -> unknown', async () => {
const err = await failWith(async () => new Response('oops', { status: 500, headers: json }));
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'http-5xx', httpStatus: 500 });
});
it('422 -> not applied', async () => {
const err = await failWith(async () => new Response('Failed to deserialize', { status: 422, headers: { 'Content-Type': 'text/plain' } }));
expect(classifyTransportFailure(err)).toBe('not-applied');
});
it('200 with invalid JSON -> unknown, transient', async () => {
const err = await failWith(async () => new Response('{"status":', { status: 200, headers: json }));
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'bad-response-body', transient: true });
});
it('200 with an HTML page (no SyntaxError in cause) -> unknown, transient', async () => {
const err = await failWith(async () => new Response('<html>502</html>', { status: 200, headers: { 'Content-Type': 'text/html' } }));
expect((err as Error).cause).toBeUndefined();
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'bad-response-body', transient: true });
});
it('network failure -> unknown', async () => {
const err = await failWith(async () => {
throw new TypeError('fetch failed');
});
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'network', transient: true });
expect(recommendRetry(err, { idempotent: false })).toBe('reconcile');
});
it('timeout -> unknown', async () => {
const hang = (_url: unknown, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason));
});
const err = await failWith(hang, 20);
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'timeout', transient: true });
});
});