src/transport/errors.ts
v0.3.0 · 9.8 KB
// Error classes and classifiers for Hyperliquid HTTP traffic.
//
// Retry safety depends on the error class:
// - 429: rejected before execution, always safe to repeat (through the limiter, with backoff).
// - 5xx / 408 / timeout / network / unparsable 200: outcome UNKNOWN. Only idempotent calls
// (/info, reduce-only closes, updateLeverage) may be retried.
// - other 4xx: not applied, do not retry.
/** Maximum number of characters of a response body kept on an error. */
export const MAX_ERROR_BODY_CHARS = 2000;
function truncate(text: string): string {
return text.length > MAX_ERROR_BODY_CHARS ? `${text.slice(0, MAX_ERROR_BODY_CHARS)}...` : text;
}
/**
* Non-2xx HTTP response from Hyperliquid.
*
* Always carries a numeric `status`: a plain `Error("... HTTP 502 ...")` without it hides the 502
* from a status-based classifier, and every 5xx then aborts the whole tick on the first attempt
* while timeouts are retried fine.
*/
export class HlHttpError extends Error {
override readonly name = 'HlHttpError';
/** HTTP status code. */
readonly status: number;
/** Response body (truncated to {@link MAX_ERROR_BODY_CHARS}), '' when unreadable. */
readonly bodyText: string;
/** Request URL. */
readonly url: string;
/** `type` of the /info request, when known. */
readonly requestType: string | undefined;
/**
* True for HTTP 500 with the literal JSON body `null`: the way /info rejects a request it cannot
* serve, e.g. `meta` for an unknown `dex` or `candleSnapshot` with a bare HIP-3 coin plus `dex`
* (live check 2026-09: both answer `500 null` on every call). {@link isTransientError} does not
* retry it - four retries would burn 5x the weight on a request that can never succeed.
*
* @experimental Inferred from live responses, not from HL documentation. A genuine transient
* failure that also answered `500 null` would surface as one failed read instead of being retried.
*/
readonly invalidRequest: boolean;
constructor(init: { status: number; bodyText?: string; url: string; requestType?: string }) {
const what = init.requestType ? ` for ${init.requestType}` : '';
super(`Hyperliquid HTTP ${init.status}${what}`);
this.status = init.status;
this.bodyText = truncate(init.bodyText ?? '');
this.url = init.url;
this.requestType = init.requestType;
this.invalidRequest = init.status === 500 && this.bodyText.trim() === 'null';
}
}
/**
* HTTP 200 whose body is not valid JSON (a proxy or load balancer returned HTML or a cut-off body).
*
* Transient: on /info it has no side effect and must be retried. The original `SyntaxError` is kept
* in `cause` - classifiers that do not walk `.cause` let this case bypass the retry layer entirely.
*/
export class HlResponseParseError extends Error {
override readonly name = 'HlResponseParseError';
readonly status: number;
readonly bodyText: string;
readonly url: string;
readonly requestType: string | undefined;
/** Marker understood by {@link isTransientError}. */
readonly transientResponseBody = true;
constructor(init: { status: number; bodyText: string; url: string; requestType?: string; cause: unknown }) {
const what = init.requestType ? ` for ${init.requestType}` : '';
super(`Hyperliquid returned an unparsable ${init.status} body${what}`, { cause: init.cause });
this.status = init.status;
this.bodyText = truncate(init.bodyText);
this.url = init.url;
this.requestType = init.requestType;
}
}
/**
* The client's own per-attempt timeout fired.
*
* Without a hard timeout a hung socket waits for undici defaults (~300 s) on every retry attempt
* and holds a concurrency slot, freezing everything queued behind it - including urgent closes.
*/
export class HlTimeoutError extends Error {
override readonly name = 'HlTimeoutError';
readonly timeoutMs: number;
readonly url: string;
readonly requestType: string | undefined;
constructor(init: { timeoutMs: number; url: string; requestType?: string; cause?: unknown }) {
const what = init.requestType ? ` (${init.requestType})` : '';
super(`Hyperliquid request timed out after ${init.timeoutMs} ms${what}`, { cause: init.cause });
this.timeoutMs = init.timeoutMs;
this.url = init.url;
this.requestType = init.requestType;
}
}
/** `fetch` rejected for a reason other than an abort (DNS, reset, refused, TLS...). Outcome unknown. */
export class HlNetworkError extends Error {
override readonly name = 'HlNetworkError';
readonly url: string;
readonly requestType: string | undefined;
constructor(init: { url: string; requestType?: string; cause: unknown }) {
const causeMsg = init.cause instanceof Error ? init.cause.message : String(init.cause);
const what = init.requestType ? ` (${init.requestType})` : '';
super(`Hyperliquid network error${what}: ${causeMsg}`, { cause: init.cause });
this.url = init.url;
this.requestType = init.requestType;
}
}
type Loose = {
status?: unknown;
statusCode?: unknown;
response?: { status?: unknown } | null;
message?: unknown;
cause?: unknown;
name?: unknown;
transientResponseBody?: unknown;
};
const MAX_CAUSE_DEPTH = 6;
/**
* SDK `ApiRequestError` (matched by name: it is not exported from the SDK root): HL processed the
* request and answered with an error body `{ status: 'ok' | 'err', response }`.
*/
function isApiResponseError(err: unknown): boolean {
if (err === null || typeof err !== 'object') return false;
const e = err as Loose;
return e.name === 'ApiRequestError' || typeof e.response?.status === 'string';
}
/**
* HTTP status of an error from this package, the SDK (`HttpRequestError.response.status`) or ad-hoc
* code. Falls back to parsing `HTTP 502` from the message, so a plain `Error("... HTTP 502 ...")`
* still classifies as 5xx.
*/
export function statusOf(err: unknown): number | undefined {
if (err === null || typeof err !== 'object') return undefined;
const e = err as Loose;
const candidates = [e.status, e.response?.status, e.statusCode];
for (const s of candidates) if (typeof s === 'number' && Number.isInteger(s)) return s;
const m = /\bhttp (\d{3})\b/i.exec(String(e.message ?? ''));
return m ? Number(m[1]) : undefined;
}
function messageOf(err: unknown): string {
if (err === null || err === undefined) return '';
if (typeof err === 'object') return String((err as Loose).message ?? '').toLowerCase();
return String(err).toLowerCase();
}
/**
* True for an IP rate-limit rejection (HTTP 429). Safe to retry even for non-idempotent actions.
*
* Matches `\b429\b`, never a bare substring: `includes('429')` fires on oids and numbers like `14290`
* and widens the retry of a non-idempotent OPEN to an ambiguous failure - a double entry.
*/
export function isRateLimitError(err: unknown): boolean {
// The exchange answered (`status: 'err'` or per-order errors): never an IP rejection. Its text
// carries prices and asset ids ("... asset=429", "px 429.5") that `\b429\b` matches, and a
// partially accepted batch may already rest - retrying it duplicates orders.
if (isApiResponseError(err)) return false;
// A known status is authoritative: a 400 or 500 whose body mentions 429 is not a rate limit.
const status = statusOf(err);
if (status !== undefined) return status === 429;
const msg = messageOf(err);
return /\b429\b/.test(msg) || msg.includes('too many requests') || msg.includes('rate limit');
}
const TRANSIENT_MESSAGES = [
'fetch failed',
'econnreset',
'etimedout',
'socket hang up',
'econnrefused',
'network',
'timeout',
'timed out',
'eai_again',
'and retry',
];
/**
* True when an error is worth retrying for an IDEMPOTENT call: 429, 408, 5xx, timeouts, network
* failures, unparsable 200 bodies (walking `.cause` up to 6 levels, or any error carrying a 2xx
* status), or an exchange reply that says "... and retry". Other 4xx, other exchange rejections
* (`ApiRequestError`), `500 null` rejections of invalid /info requests
* ({@link HlHttpError.invalidRequest}) and caller-requested aborts (`AbortError`) are not transient.
*
* For non-idempotent exchange actions (open/increase orders, TP/SL, transfers) use
* {@link isRateLimitError} instead: after a 5xx or timeout the action may have been applied.
*/
export function isTransientError(err: unknown): boolean {
if (isRateLimitError(err)) return true;
let cur: unknown = err;
for (let depth = 0; cur !== null && cur !== undefined && depth <= MAX_CAUSE_DEPTH; depth++) {
if (
cur instanceof HlTimeoutError ||
cur instanceof HlNetworkError ||
cur instanceof HlResponseParseError ||
cur instanceof SyntaxError
) {
return true;
}
if (typeof cur !== 'object') {
const msg = messageOf(cur);
return TRANSIENT_MESSAGES.some((x) => msg.includes(x));
}
const e = cur as Loose;
if (e.transientResponseBody === true || e.name === 'SyntaxError' || e.name === 'TimeoutError') return true;
if (e.name === 'AbortError') return false;
if (cur instanceof HlHttpError && cur.invalidRequest) return false;
if (isApiResponseError(cur)) {
// An explicit exchange answer: only "... and retry" invites a repeat. Words such as
// "timeout" inside a rejection text must not turn it into a transport failure.
return messageOf(cur).includes('and retry');
}
const s = statusOf(cur);
if (s === 408 || (s !== undefined && s >= 500 && s <= 599)) return true;
if (s !== undefined && s >= 400 && s <= 499) return false;
// An error carrying a 2xx status means the body was unusable. SDK 0.33 throws
// HttpRequestError(200) WITHOUT a SyntaxError cause when the content-type is not JSON
// (a balancer's HTML page), so walking `.cause` alone misses it.
if (s !== undefined && s >= 200 && s <= 299) return true;
const msg = messageOf(cur);
if (TRANSIENT_MESSAGES.some((x) => msg.includes(x))) return true;
cur = e.cause;
}
return false;
}