Skip to content
markpaper

src/errors/sdk-contract.test.ts

v0.3.0 · 5.8 KB

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