src/errors/classify.ts
v0.3.0 · 16.5 KB
// 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';
}