Skip to content
markpaper

src/errors/classify.ts

v0.3.0 · 16.5 KB

Download file
// Classification of Hyperliquid /exchange error strings.
//
// The exchange reports rejections as free text: `{ status: 'err', response: '<text>' }` for a whole
// action, `statuses[i].error` for one order of a batch, `data.status.error` for TWAP. Bots branch
// on that text (hold an order, re-place next tick, stop a dead key), so the patterns below are the
// single place where the text is interpreted.

/**
 * Normalized reason of an exchange-side rejection.
 *
 * Kinds tagged `@experimental` use unverified wording or documentation-only patterns. Risk: if the
 * real text differs, the error classifies as `'unknown'`
 * (or, for `positionLimit`, as `leverage`), so callers must keep a conservative `'unknown'` path.
 */
export type ExchangeErrorKind =
  /** `User or API Wallet 0x... does not exist.` - agent key unauthorized, expired or revoked. */
  | 'agentNotAuthorized'
  /** `Abstraction transition not allowed` from agentEnableDexAbstraction: already enabled, a success. */
  | 'abstractionAlreadyEnabled'
  /** `Action disabled when unified account is active` (usdSend, usdClassTransfer). */
  | 'unifiedAccountActionDisabled'
  /**
   * `Too many cumulative requests ...` - the per-address action budget is exhausted.
   * @experimental Exact text of this error was not verified live.
   */
  | 'addressRateLimit'
  /** HTTP 429 / `Too Many Requests` / `rate limit` text. */
  | 'rateLimited'
  /** `... please wait and retry`. */
  | 'retryLater'
  /** `Order was never placed, already canceled, or filled.` (also for TWAP). */
  | 'orderNotFound'
  /** `Order must have minimum value of $10.` */
  | 'minNotional'
  /**
   * `Order could not immediately match against any resting orders.` (IoC).
   * @experimental Exact wording has not been checked against a live response.
   */
  | 'iocNoMatch'
  /** `Post only order would have immediately matched...` / `badAloPxRejected`. */
  | 'postOnlyWouldCross'
  /**
   * `Reduce only order would increase position.`
   * @experimental Exact wording has not been checked against a live response.
   */
  | 'reduceOnlyWouldIncrease'
  /**
   * `Order would cause position to exceed margin tier limit at current leverage.`
   * @experimental Documentation wording; without it the text would classify as `leverage`/`unknown`.
   */
  | 'positionLimit'
  /** Leverage change rejected (e.g. `Isolated position does not have sufficient margin available to decrease leverage`). */
  | 'leverage'
  /** `Insufficient margin ...` / `perpMarginRejected`. */
  | 'insufficientMargin'
  /**
   * `Invalid TP/SL price.`
   * @experimental Documentation wording.
   */
  | 'invalidTriggerPrice'
  /** `Order has invalid price.` (the tick-size wording is documentation-only). */
  | 'invalidPrice'
  /**
   * `Price too far from oracle.` / `... away from the reference price`.
   * @experimental Documentation wording.
   */
  | 'priceTooFarFromOracle'
  /**
   * `Order has invalid size.` / `Order has zero size.`
   * @experimental The knowledge base records size rejections without their text.
   */
  | 'invalidSize'
  /**
   * Open interest cap rejections.
   * @experimental Documentation wording.
   */
  | 'openInterestCap'
  /**
   * Builder fee not approved or above the approved maximum.
   * @experimental The knowledge base records only the REJECTED outcome, not the text.
   */
  | 'builderFee'
  /** `Invalid TWAP duration: 1 min(s)`. */
  | 'invalidTwapDuration'
  /** Any other `...Rejected` order status. */
  | 'rejected'
  /** Unrecognized text. */
  | 'unknown';

/**
 * What a caller should do about a rejection.
 *
 * - `permanent`: repeating the same request fails the same way. Do not hot-retry; hold the order
 *   (see `holdMs`) or fix the inputs. An endless re-place loop burns the address request budget.
 * - `transient`: the market moved; re-plan and re-send on the next tick with fresh data.
 * - `retryable`: not applied; the same request may be re-sent with backoff, even a non-idempotent one.
 * - `noop`: the target state probably already holds (order gone, nothing to reduce). Stop retrying,
 *   but reconcile: the position may have changed underneath.
 * - `benign`: treat as success.
 * - `fatal`: credential-level; every request with this key will fail. Stop trading with it and alert.
 * - `unsupported`: this action will never work on this account; remember that and stop calling it.
 * - `unknown`: unrecognized text; handle conservatively and log the raw string.
 */
export type ErrorDisposition =
  | 'permanent'
  | 'transient'
  | 'retryable'
  | 'noop'
  | 'benign'
  | 'fatal'
  | 'unsupported'
  | 'unknown';

/** Handling metadata for an {@link ExchangeErrorKind}. */
export interface ErrorKindInfo {
  readonly disposition: ErrorDisposition;
  /** Suggested pause before trying the same thing again, ms (only where the knowledge base gives one). */
  readonly holdMs?: number;
  /**
   * `true` when the matching text comes from exchange documentation and was not verified against
   * live responses. Such a pattern may miss the real wording (the kind then degrades to
   * `unknown`), so never make a safety decision depend on it alone.
   */
  readonly experimental: boolean;
  /** One-line handling hint. */
  readonly hint: string;
}

/** Pause for rejections that repeat until inputs change (min notional, margin, leverage): 30 s. */
export const PERMANENT_REJECT_HOLD_MS = 30_000;

/** Pause of placements after an exchange-side rate limit: 10 s (a recommended default). */
export const RATE_LIMIT_PAUSE_MS = 10_000;

const INFO: Readonly<Record<ExchangeErrorKind, ErrorKindInfo>> = {
  agentNotAuthorized: {
    disposition: 'fatal',
    experimental: false,
    hint: 'API wallet not authorized, expired or revoked: every request fails and retries are useless; create and Authorize a new API wallet.',
  },
  abstractionAlreadyEnabled: {
    disposition: 'benign',
    experimental: false,
    hint: 'Account is already unified or has dex abstraction; the agent inherits HIP-3 access.',
  },
  unifiedAccountActionDisabled: {
    disposition: 'unsupported',
    experimental: false,
    hint: 'Legacy transfer (usdSend / usdClassTransfer) is disabled on a unified account; use sendAsset and stop retrying.',
  },
  addressRateLimit: {
    disposition: 'retryable',
    holdMs: RATE_LIMIT_PAUSE_MS,
    experimental: true,
    hint: 'Address action budget exhausted (about 1 request per 10 s); trade volume, reserveRequestWeight or a sub-account restore it.',
  },
  rateLimited: {
    disposition: 'retryable',
    holdMs: RATE_LIMIT_PAUSE_MS,
    experimental: false,
    hint: 'Rate limited before execution; resend through the limiter with backoff.',
  },
  retryLater: {
    disposition: 'retryable',
    experimental: false,
    hint: 'The exchange asked to wait and retry.',
  },
  orderNotFound: {
    disposition: 'noop',
    experimental: false,
    hint: 'Order is not in the book (never placed, canceled or filled); forget it locally, but the position may have changed.',
  },
  minNotional: {
    disposition: 'permanent',
    holdMs: PERMANENT_REJECT_HOLD_MS,
    experimental: false,
    hint: 'Below the $10 minimum at the order price after rounding; check before sending. A leftover below the minimum is dust for a human.',
  },
  iocNoMatch: {
    disposition: 'transient',
    experimental: true,
    hint: 'IoC did not cross the book; check the spread and widen the slippage cap.',
  },
  postOnlyWouldCross: {
    disposition: 'transient',
    experimental: false,
    hint: 'Post-only (Alo) order would cross; re-place next tick one tick behind the opposite best.',
  },
  reduceOnlyWouldIncrease: {
    disposition: 'noop',
    experimental: true,
    hint: 'Reduce-only with no position (or in its direction); treat as no-op and re-read the position.',
  },
  positionLimit: {
    disposition: 'permanent',
    holdMs: PERMANENT_REJECT_HOLD_MS,
    experimental: true,
    hint: 'Position would exceed the margin tier limit at the current leverage; reduce size or leverage.',
  },
  leverage: {
    disposition: 'permanent',
    holdMs: PERMANENT_REJECT_HOLD_MS,
    experimental: false,
    hint: 'Leverage rejected (above maxLeverage, cross on an isolated-only asset, open isolated position); hold only opening orders.',
  },
  insufficientMargin: {
    disposition: 'permanent',
    holdMs: PERMANENT_REJECT_HOLD_MS,
    experimental: false,
    hint: 'Not enough margin (resting orders reserve margin too); run cancels before placements and keep book utilization well below 100%.',
  },
  invalidTriggerPrice: {
    disposition: 'permanent',
    experimental: true,
    hint: 'TP/SL trigger price rejected; recompute trigger levels.',
  },
  invalidPrice: {
    disposition: 'permanent',
    experimental: false,
    hint: 'Price off the grid (5 significant figures and 6 - szDecimals decimals for perps, 8 - szDecimals for spot; integers always allowed); quantize before sending.',
  },
  priceTooFarFromOracle: {
    disposition: 'permanent',
    holdMs: PERMANENT_REJECT_HOLD_MS,
    experimental: true,
    hint: 'Limit price too far from the oracle/reference price; re-price from a fresh mid.',
  },
  invalidSize: {
    disposition: 'permanent',
    experimental: true,
    hint: 'Size rejected; floor to 10^-szDecimals lots before sending.',
  },
  openInterestCap: {
    disposition: 'permanent',
    holdMs: PERMANENT_REJECT_HOLD_MS,
    experimental: true,
    hint: 'Asset is at its open interest cap; only reducing orders can pass.',
  },
  builderFee: {
    disposition: 'permanent',
    experimental: true,
    hint: 'Builder fee not approved or above the approved maximum; re-check maxBuilderFee and never attach a builder to protective orders.',
  },
  invalidTwapDuration: {
    disposition: 'permanent',
    experimental: false,
    hint: 'TWAP duration out of range (1 minute is rejected).',
  },
  rejected: {
    disposition: 'permanent',
    experimental: false,
    hint: 'Terminal *Rejected order status.',
  },
  unknown: {
    disposition: 'unknown',
    experimental: false,
    hint: 'Unrecognized error text; log the raw string and do not assume the order is gone.',
  },
};

/** Handling metadata (disposition, hold, experimental flag, hint) for a kind. */
export function errorKindInfo(kind: ExchangeErrorKind): ErrorKindInfo {
  return INFO[kind];
}

/**
 * Rate-limit wording: `Too Many Requests`, `rate limit`, or a standalone `429`.
 *
 * The knowledge base prescribes `\b429\b` instead of `includes('429')` (the substring fires on oids
 * like 14290 and can widen the retry of a non-idempotent open into a double entry). `\b` alone is still
 * too loose for exchange texts: it matches after `.`, `=`, `@` or `$`, so `bbo was [email protected]` or
 * `asset=429` would read as a rate limit and turn a rejection into "safe to retry". Here the number must
 * stand alone (not part of a decimal, price, id or assignment) or follow `status` / `code`.
 * @internal
 */
export const RATE_LIMIT_TEXT =
  /too many requests|rate[\s-]?limit|(?<![\w.,=@$#:+\-/])429(?![\w.,@%/])|\b(?:status|code|statusCode)\s*[=:]\s*429(?![\w.])/i;

interface Rule {
  readonly kind: ExchangeErrorKind;
  readonly re: RegExp;
}

// Order matters: the first match wins. Comments explain the non-obvious precedences.
const RULES: readonly Rule[] = [
  // Exact reply: `User or API Wallet 0x... does not exist.` The address varies per user, so match
  // the stable parts. Checked first: it is the one rejection that must stop the whole account (a
  // deauthorized key can neither cancel nor close, so open positions stay unmanaged). The broader
  // /does not exist|not approved|unauthorized/ is deliberately NOT used here: "not approved" would
  // also match builder-fee rejections and raise a false dead-key alarm.
  { kind: 'agentNotAuthorized', re: /user or api wallet\s.*\bdoes not exist/i },
  // agentEnableDexAbstraction on an already unified account: a success, not "HIP-3 will not work";
  // HIP-3 orders go through.
  { kind: 'abstractionAlreadyEnabled', re: /transition\s+not\s+allowed/i },
  // `Action disabled when unified account is active` (usdSend, usdClassTransfer).
  { kind: 'unifiedAccountActionDisabled', re: /disabled when unified account|unified account is active/i },
  // Address budget. Wording from documentation, not verified live. Before `rateLimited`.
  { kind: 'addressRateLimit', re: /too many cumulative requests/i },
  // `Order was never placed, already canceled, or filled.` (and the TWAP variant). Kept narrow on
  // purpose: a loose /filled/ could read an unrelated cancel failure as "order gone", and a
  // replacement placed over a still-resting order duplicates it.
  { kind: 'orderNotFound', re: /never placed|already cancell?ed/i },
  // `Order must have minimum value of $10.` (spot: `... of 10 <QUOTE>`).
  { kind: 'minNotional', re: /minimum value of/i },
  // `Order could not immediately match against any resting orders.` Must precede the post-only rule:
  // the common /immediately|post only|alo/ regex would file an IoC miss under post-only.
  { kind: 'iocNoMatch', re: /could not immediately match|no liquidity available for market order/i },
  // `Post only order would have immediately matched, bbo was <bid>@<ask>`; `badAloPxRejected` in
  // order updates. `alo` only as a whole word, so "along" or "halo" never match.
  { kind: 'postOnlyWouldCross', re: /post[\s-]?only|immediately matched|badAloPx|\balo\b/i },
  // `Reduce only order would increase position.`
  { kind: 'reduceOnlyWouldIncrease', re: /reduce[\s_-]?only\b.*\bwould increase/i },
  // `Order would cause position to exceed margin tier limit at current leverage.` (documentation).
  // Before `leverage`, which would otherwise swallow it.
  { kind: 'positionLimit', re: /margin tier limit/i },
  // `Isolated position does not have sufficient margin available to decrease leverage`, invalid or
  // above-max leverage. Before `insufficientMargin` so a leverage change is not read as order margin.
  { kind: 'leverage', re: /leverage/i },
  // `Insufficient margin ...`; `perpMarginRejected` in order updates.
  { kind: 'insufficientMargin', re: /insufficient margin|marginRejected/i },
  // `Invalid TP/SL price.` (documentation). Before the generic price rule.
  { kind: 'invalidTriggerPrice', re: /invalid tp\/sl price|invalid trigger price/i },
  // `Order has invalid price.`; `Price must be divisible by tick size.` (documentation).
  { kind: 'invalidPrice', re: /invalid price|tick size/i },
  // `Price too far from oracle.`; `Order price cannot be more than 95% away from the reference
  // price` (documentation).
  { kind: 'priceTooFarFromOracle', re: /too far from oracle|away from the reference price|price band/i },
  // `Order has invalid size.` / `Order has zero size.` (documentation).
  { kind: 'invalidSize', re: /invalid size|zero size/i },
  // `Order would increase position at open interest cap.` and siblings (documentation).
  { kind: 'openInterestCap', re: /open\s*interest/i },
  // Exact wording not verified; only builder-related errors mention the builder fee.
  { kind: 'builderFee', re: /builder fee/i },
  // `Invalid TWAP duration: 1 min(s)`.
  { kind: 'invalidTwapDuration', re: /invalid twap duration/i },
  // Generic rate-limit / retry wording comes AFTER every order-specific rule: exchange texts carry
  // numbers (`bbo was [email protected]`, `asset=429`), and a specific reason must never be overridden by
  // a number that happens to read like an HTTP status.
  { kind: 'rateLimited', re: RATE_LIMIT_TEXT },
  // `... please wait and retry`.
  { kind: 'retryLater', re: /\band retry\b/i },
  // Any order status ending in `Rejected` is a terminal rejection.
  { kind: 'rejected', re: /[a-z]Rejected\b/ },
];

function textOf(input: unknown): string {
  if (typeof input === 'string') return input;
  if (input !== null && typeof input === 'object') {
    const o = input as { error?: unknown; message?: unknown };
    if (typeof o.error === 'string') return o.error;
    if (typeof o.message === 'string') return o.message;
    return '';
  }
  return input === null || input === undefined ? '' : String(input);
}

/**
 * Maps an exchange error text to a normalized {@link ExchangeErrorKind}; unmatched text gives
 * `'unknown'`.
 *
 * Accepts the raw string, a `{ error }` status element, or an error object (its `message` is used).
 * An SDK `ApiRequestError` for a batch carries a joined message (`"order 1: ..., order 3: ..."`) and
 * matches by rule priority, not by position - classify each `statuses[i]` instead (see
 * `parseOrderStatuses` / `extractPartialBatch`).
 *
 * Kinds whose wording has not been verified live are flagged `experimental` in
 * {@link errorKindInfo}.
 */
export function classifyExchangeError(message: unknown): ExchangeErrorKind {
  const text = textOf(message);
  if (!text) return 'unknown';
  for (const rule of RULES) if (rule.re.test(text)) return rule.kind;
  return 'unknown';
}
All files