Skip to content
markpaper

src/errors/transport.test.ts

v0.3.0 · 8.5 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  classifyTransportFailure,
  describeTransportFailure,
  httpStatusOf,
  isRateLimitFailure,
  isTransientFailure,
} from './transport.js';

/** Shape of SDK 0.33.3 HttpRequestError (response is a Response clone; only `status` matters here). */
function httpRequestError(status: number | undefined, message: string, cause?: unknown): Error {
  const err = new Error(message, cause === undefined ? undefined : { cause });
  return Object.assign(err, {
    name: 'HttpRequestError',
    response: status === undefined ? undefined : { status },
  });
}

const named = (name: string, message: string, extra: object = {}) => Object.assign(new Error(message), { name }, extra);

describe('classifyTransportFailure: HTTP statuses', () => {
  it('4xx except 408 (429 included) -> not applied', () => {
    for (const s of [400, 401, 403, 404, 422, 429]) {
      expect(classifyTransportFailure(httpRequestError(s, `${s} - body`))).toBe('not-applied');
    }
  });

  it('408 and 5xx -> unknown', () => {
    for (const s of [408, 500, 502, 503, 504]) {
      expect(classifyTransportFailure(httpRequestError(s, `${s}`))).toBe('unknown');
    }
  });

  it('works with HlHttpError-like { status } objects and plain status objects', () => {
    expect(classifyTransportFailure(named('HlHttpError', 'Hyperliquid HTTP 403', { status: 403 }))).toBe('not-applied');
    expect(classifyTransportFailure({ status: 502 })).toBe('unknown');
    expect(classifyTransportFailure({ statusCode: 429 })).toBe('not-applied');
  });

  it('parses "HTTP nnn" from a plain message when no status field exists', () => {
    const err = new Error('HL info HTTP 502 for {"type":"meta"}');
    expect(httpStatusOf(err)).toBe(502);
    expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'http-5xx', transient: true });
  });
});

describe('classifyTransportFailure: no response', () => {
  it('timeout, abort and network failures -> unknown', () => {
    const timeout = httpRequestError(undefined, 'Request timed out after 10000 ms', named('TimeoutError', 'signal timed out'));
    const abort = httpRequestError(undefined, 'Request aborted', named('AbortError', 'This operation was aborted'));
    const network = httpRequestError(
      undefined,
      'Unknown HTTP request error: fetch failed',
      Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNRESET' } }),
    );
    expect(describeTransportFailure(timeout)).toMatchObject({ outcome: 'unknown', reason: 'timeout', transient: true });
    expect(describeTransportFailure(abort)).toMatchObject({ outcome: 'unknown', reason: 'aborted', transient: false });
    expect(describeTransportFailure(network)).toMatchObject({ outcome: 'unknown', reason: 'network', transient: true });
    expect(httpStatusOf(timeout)).toBeUndefined();
  });

  it('kit transport errors -> unknown', () => {
    expect(describeTransportFailure(named('HlTimeoutError', 'Hyperliquid request timed out after 8000 ms'))).toMatchObject({
      outcome: 'unknown',
      reason: 'timeout',
    });
    expect(describeTransportFailure(named('HlNetworkError', 'Hyperliquid network error: socket hang up'))).toMatchObject({
      outcome: 'unknown',
      reason: 'network',
    });
  });

  it('unrecognized errors are unknown (fail-closed)', () => {
    expect(describeTransportFailure(new Error('something odd'))).toMatchObject({ outcome: 'unknown', reason: 'unrecognized', transient: false });
    expect(classifyTransportFailure(undefined)).toBe('unknown');
    expect(classifyTransportFailure('boom')).toBe('unknown');
  });
});

describe('classifyTransportFailure: unreadable 200 body', () => {
  it('invalid JSON with SyntaxError in cause -> unknown, transient', () => {
    const err = httpRequestError(200, '200 OK - Invalid JSON response body', new SyntaxError('Unexpected token <'));
    expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'bad-response-body', transient: true });
  });

  it('HTML from a load balancer WITHOUT SyntaxError (SDK 0.33.3) is still transient', () => {
    const err = httpRequestError(200, '200 OK - <html>bad gateway</html>');
    expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'bad-response-body', transient: true });
    expect(isTransientFailure(err)).toBe(true);
  });

  it('kit HlResponseParseError -> unknown', () => {
    const err = named('HlResponseParseError', 'Hyperliquid returned an unparsable 200 body', { status: 200, transientResponseBody: true });
    expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'bad-response-body' });
  });
});

describe('classifyTransportFailure: SDK-local and exchange errors', () => {
  it('ValidationError and AbstractWalletError -> not applied', () => {
    expect(describeTransportFailure(named('ValidationError', 'Invalid length: Expected 34 but received 6'))).toMatchObject({
      outcome: 'not-applied',
      reason: 'validation',
      transient: false,
    });
    expect(describeTransportFailure(named('AbstractWalletError', 'Failed to sign'))).toMatchObject({
      outcome: 'not-applied',
      reason: 'signing',
    });
  });

  it('ApiRequestError: whole rejection not applied; batch with errors is unknown', () => {
    const rejected = named('ApiRequestError', 'Insufficient margin', { response: { status: 'err', response: 'Insufficient margin' } });
    expect(describeTransportFailure(rejected)).toMatchObject({ outcome: 'not-applied', reason: 'exchange-rejected', transient: false });
    const retry = named('ApiRequestError', 'busy, please wait and retry', { response: { status: 'err', response: 'busy, please wait and retry' } });
    expect(describeTransportFailure(retry)).toMatchObject({ outcome: 'not-applied', transient: true });
    const partial = named('ApiRequestError', 'order 1: x', {
      response: { status: 'ok', response: { type: 'order', data: { statuses: [{ resting: { oid: 1 } }, { error: 'x' }] } } },
    });
    expect(describeTransportFailure(partial)).toMatchObject({ outcome: 'unknown', reason: 'exchange-partial' });
  });

  it('never reads an ApiRequestError body status as an HTTP status', () => {
    const err = named('ApiRequestError', 'x', { response: { status: 500 } });
    expect(httpStatusOf(err)).toBeUndefined();
  });

  it('WebSocket: a frame never sent is not applied, anything else unknown', () => {
    expect(classifyTransportFailure(named('WebSocketRequestError', 'WebSocket connection closed before the request was sent'))).toBe('not-applied');
    expect(describeTransportFailure(named('WebSocketRequestError', 'WebSocket connection closed'))).toMatchObject({ outcome: 'unknown', reason: 'ws-failed' });
    expect(describeTransportFailure(named('WebSocketRequestError', 'Request timed out after 10000 ms'))).toMatchObject({ reason: 'timeout' });
    expect(describeTransportFailure(named('WebSocketRequestError', 'Request aborted'))).toMatchObject({ reason: 'aborted' });
  });

  it('finds the signal in a wrapped cause chain', () => {
    const inner = httpRequestError(429, '429 Too Many Requests');
    const outer = new Error('limiter gave up', { cause: inner });
    expect(describeTransportFailure(outer)).toMatchObject({ outcome: 'not-applied', rateLimited: true, httpStatus: 429 });
    expect(describeTransportFailure(outer).message).toBe('limiter gave up');
  });
});

describe('isRateLimitFailure', () => {
  it('matches status 429 and rate-limit text, not 429 inside numbers', () => {
    expect(isRateLimitFailure(httpRequestError(429, '429'))).toBe(true);
    expect(isRateLimitFailure(new Error('Too Many Requests'))).toBe(true);
    expect(isRateLimitFailure(new Error('rate limit'))).toBe(true);
    expect(isRateLimitFailure(new Error('cancel oid 14290 failed'))).toBe(false);
    expect(isRateLimitFailure(new Error('HTTP 500'))).toBe(false);
    expect(classifyTransportFailure(new Error('cancel oid 14290 failed'))).toBe('unknown');
  });
});

describe('isTransientFailure', () => {
  it('true for 429, 408, 5xx, timeouts, network; false for 4xx, validation, aborts', () => {
    expect(isTransientFailure(httpRequestError(429, '429'))).toBe(true);
    expect(isTransientFailure(httpRequestError(408, '408'))).toBe(true);
    expect(isTransientFailure(httpRequestError(503, '503'))).toBe(true);
    expect(isTransientFailure(new Error('ETIMEDOUT'))).toBe(true);
    expect(isTransientFailure(httpRequestError(400, '400'))).toBe(false);
    expect(isTransientFailure(named('ValidationError', 'x'))).toBe(false);
    expect(isTransientFailure(named('AbortError', 'aborted'))).toBe(false);
  });
});
All files