Skip to content
markpaper

src/transport/errors.test.ts

v0.3.0 · 9.9 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  HlHttpError,
  HlNetworkError,
  HlResponseParseError,
  HlTimeoutError,
  MAX_ERROR_BODY_CHARS,
  isRateLimitError,
  isTransientError,
  statusOf,
} from './errors.js';

const URL_ = 'https://api.hyperliquid.xyz/info';

describe('HlHttpError', () => {
  it('carries status, bodyText, url and type', () => {
    const e = new HlHttpError({ status: 422, bodyText: 'unknown dex', url: URL_, requestType: 'clearinghouseState' });
    expect(e).toBeInstanceOf(Error);
    expect(e.name).toBe('HlHttpError');
    expect(e.status).toBe(422);
    expect(e.bodyText).toBe('unknown dex');
    expect(e.url).toBe(URL_);
    expect(e.requestType).toBe('clearinghouseState');
    expect(e.message).toContain('HTTP 422');
  });

  it('truncates long bodies', () => {
    const e = new HlHttpError({ status: 502, bodyText: 'x'.repeat(10_000), url: URL_ });
    expect(e.bodyText.length).toBeLessThanOrEqual(MAX_ERROR_BODY_CHARS + 3);
  });
});

describe('statusOf', () => {
  it('reads status, response.status (SDK HttpRequestError) and statusCode', () => {
    expect(statusOf({ status: 429 })).toBe(429);
    expect(statusOf({ name: 'HttpRequestError', response: { status: 503 } })).toBe(503);
    expect(statusOf({ statusCode: 500 })).toBe(500);
  });

  it('parses "HTTP 502" from a plain message (otherwise errors without .status are never retried)', () => {
    expect(statusOf(new Error('HL info HTTP 502 for {"type":"frontendOpenOrders"}'))).toBe(502);
  });

  it('returns undefined when nothing is known', () => {
    expect(statusOf(new Error('boom'))).toBeUndefined();
    expect(statusOf(null)).toBeUndefined();
    expect(statusOf('HTTP 500')).toBeUndefined();
  });
});

describe('isRateLimitError', () => {
  it('detects 429 by status and by message', () => {
    expect(isRateLimitError(new HlHttpError({ status: 429, url: URL_ }))).toBe(true);
    expect(isRateLimitError({ response: { status: 429 } })).toBe(true);
    expect(isRateLimitError(new Error('HL 429 rate limit'))).toBe(true);
    expect(isRateLimitError(new Error('429 Too Many Requests'))).toBe(true);
    expect(isRateLimitError(new Error('Too many requests'))).toBe(true);
  });

  it('does not fire on 429 inside numbers or hex ids (would widen retries of non-idempotent orders)', () => {
    expect(isRateLimitError(new Error('order 14290 rejected'))).toBe(false);
    expect(isRateLimitError(new Error('oid 942911 insufficient margin'))).toBe(false);
    expect(isRateLimitError(new Error('cloid 0xab429cd failed'))).toBe(false);
    expect(isRateLimitError(new HlHttpError({ status: 500, url: URL_ }))).toBe(false);
  });
});

describe('isTransientError', () => {
  it('429, 408 and 5xx are transient', () => {
    expect(isTransientError(new HlHttpError({ status: 429, url: URL_ }))).toBe(true);
    expect(isTransientError(new HlHttpError({ status: 408, url: URL_ }))).toBe(true);
    expect(isTransientError(new HlHttpError({ status: 500, url: URL_ }))).toBe(true);
    expect(isTransientError(new HlHttpError({ status: 599, url: URL_ }))).toBe(true);
    expect(isTransientError(new Error('HL info HTTP 502 for {}'))).toBe(true);
  });

  it('other 4xx are final', () => {
    expect(isTransientError(new HlHttpError({ status: 400, url: URL_ }))).toBe(false);
    expect(isTransientError(new HlHttpError({ status: 422, bodyText: 'network timeout words', url: URL_ }))).toBe(false);
  });

  it('timeouts, network errors and unparsable bodies are transient', () => {
    expect(isTransientError(new HlTimeoutError({ timeoutMs: 10, url: URL_ }))).toBe(true);
    expect(isTransientError(new HlNetworkError({ url: URL_, cause: new TypeError('fetch failed') }))).toBe(true);
    expect(isTransientError(new HlResponseParseError({ status: 200, bodyText: '<html>', url: URL_, cause: new SyntaxError('x') }))).toBe(true);
    expect(isTransientError(new TypeError('fetch failed'))).toBe(true);
    expect(isTransientError(new Error('read ECONNRESET'))).toBe(true);
    expect(isTransientError(new Error('getaddrinfo EAI_AGAIN'))).toBe(true);
    expect(isTransientError(Object.assign(new Error('The operation was aborted due to timeout'), { name: 'TimeoutError' }))).toBe(true);
    expect(isTransientError(new Error('Something went wrong, please wait and retry'))).toBe(true);
  });

  it('walks .cause to find a SyntaxError (200 + HTML from a load balancer)', () => {
    const sdkLike = Object.assign(new Error('Failed to parse'), { name: 'HttpRequestError', cause: new SyntaxError('Unexpected token <') });
    expect(isTransientError(sdkLike)).toBe(true);
    const marker = Object.assign(new Error('x'), { transientResponseBody: true });
    expect(isTransientError(new Error('outer', { cause: marker }))).toBe(true);
  });

  it('stops walking after 6 levels', () => {
    let e: Error = new SyntaxError('deep');
    for (let i = 0; i < 10; i++) e = new Error(`level ${i}`, { cause: e });
    expect(isTransientError(e)).toBe(false);
  });

  it('caller aborts and plain errors are not transient', () => {
    expect(isTransientError(new DOMException('This operation was aborted', 'AbortError'))).toBe(false);
    expect(isTransientError(new Error('insufficient margin'))).toBe(false);
    expect(isTransientError(undefined)).toBe(false);
    expect(isTransientError('fetch failed')).toBe(true);
  });
});

describe('real SDK 0.33 errors', async () => {
  const { HttpRequestError } = await import('@nktkas/hyperliquid');
  const { ApiRequestError } = await import('@nktkas/hyperliquid/api/exchange');
  const res = (status: number, statusText = '', body = '') => new Response(body || null, { status, statusText });

  it('HttpRequestError 429 is a rate limit, 400/500 whose body mentions 429 is not', () => {
    const e429 = new HttpRequestError({ response: res(429, 'Too Many Requests'), detail: 'null' });
    expect(isRateLimitError(e429)).toBe(true);
    expect(isTransientError(e429)).toBe(true);
    const e400 = new HttpRequestError({ response: res(400, 'Bad Request'), detail: 'bad asset 429' });
    expect(e400.message).toMatch(/\b429\b/);
    expect(isRateLimitError(e400)).toBe(false);
    expect(isTransientError(e400)).toBe(false);
    const e500 = new HttpRequestError({ response: res(500, 'Internal Server Error'), detail: 'rate limit backend 429' });
    expect(isRateLimitError(e500)).toBe(false);
    expect(isTransientError(e500)).toBe(true);
  });

  it('HttpRequestError 200 with a non-JSON content-type (no SyntaxError cause) is transient', () => {
    const html = new HttpRequestError({ response: res(200, 'OK'), detail: '<html>502 Bad Gateway</html>' });
    expect(html.cause).toBeUndefined();
    expect(isTransientError(html)).toBe(true);
    expect(isRateLimitError(html)).toBe(false);
    const badJson = new HttpRequestError({ response: res(200), detail: 'Invalid JSON response body', cause: new SyntaxError('x') });
    expect(isTransientError(badJson)).toBe(true);
  });

  it('SDK timeout and network failures are transient, a caller abort is not', () => {
    const timeout = new HttpRequestError({ detail: 'Request timed out after 10000 ms', cause: new DOMException('Signal timed out.', 'TimeoutError') });
    expect(isTransientError(timeout)).toBe(true);
    expect(isRateLimitError(timeout)).toBe(false);
    expect(isTransientError(new HttpRequestError({ cause: new TypeError('fetch failed') }))).toBe(true);
    const aborted = new HttpRequestError({ detail: 'Request aborted', cause: new DOMException('This operation was aborted', 'AbortError') });
    expect(isTransientError(aborted)).toBe(false);
    expect(isRateLimitError(aborted)).toBe(false);
  });

  it('ApiRequestError never counts as a 429, even when prices or asset ids contain 429', () => {
    // A partially accepted batch: order 0 rests, order 1 is rejected. Retrying it duplicates order 0.
    const partial = new ApiRequestError(
      { status: 'ok', response: { type: 'order', data: { statuses: [{ resting: { oid: 1 } }, { error: 'Order has invalid price. asset=429' }] } } },
      'order 1: Order has invalid price. asset=429',
    );
    expect(isRateLimitError(partial)).toBe(false);
    expect(isTransientError(partial)).toBe(false);
    const px = new ApiRequestError({ status: 'err', response: 'Order price 429.5 too far from oracle' }, 'Order price 429.5 too far from oracle');
    expect(isRateLimitError(px)).toBe(false);
    const words = new ApiRequestError({ status: 'err', response: 'x' }, 'Order timeout network rate limit');
    expect(isRateLimitError(words)).toBe(false);
    expect(isTransientError(words)).toBe(false);
  });

  it('an exchange answer that says "... and retry" stays transient', () => {
    const e = new ApiRequestError({ status: 'err', response: 'x' }, 'Node is busy, please wait and retry');
    expect(isTransientError(e)).toBe(true);
    expect(isRateLimitError(e)).toBe(false);
  });
});

describe('HlHttpError.invalidRequest (500 null)', () => {
  it('500 with the literal body null (unknown dex, bare HIP-3 candle coin + dex) is final', () => {
    const e = new HlHttpError({ status: 500, bodyText: 'null', url: URL_, requestType: 'meta' });
    expect(e.invalidRequest).toBe(true);
    expect(isTransientError(e)).toBe(false);
    expect(isTransientError(new HlHttpError({ status: 500, bodyText: ' null\n', url: URL_ }))).toBe(false);
    expect(isTransientError(new Error('wrapped', { cause: e }))).toBe(false);
  });

  it('other 5xx bodies stay transient', () => {
    for (const [status, body] of [[500, ''], [500, '<html>'], [502, 'null'], [503, 'null ']] as const) {
      const e = new HlHttpError({ status, bodyText: body, url: URL_ });
      expect(e.invalidRequest).toBe(status === 500 && body.trim() === 'null');
      expect(isTransientError(e)).toBe(!(status === 500 && body.trim() === 'null'));
    }
  });

  it('a known status is authoritative for rate-limit detection', () => {
    expect(isRateLimitError(new HlHttpError({ status: 503, bodyText: 'rate limit', url: URL_ }))).toBe(false);
    expect(isRateLimitError({ status: 400, message: 'Too many requests' })).toBe(false);
    expect(isRateLimitError({ message: 'HTTP 429 from proxy' })).toBe(true);
  });
});
All files