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