Skip to content
markpaper

src/transport/errors.ts

v0.3.0 · 9.8 KB

Download file
// 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;
}
All files