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