Skip to content
markpaper

src/errors/interpret.ts

v0.3.0 · 6 KB

Download file
// One entry point for "an exchange call threw - what happened and what now?".

import { classifyExchangeError, errorKindInfo, type ExchangeErrorKind } from './classify.js';
import {
  exchangeErrorBody,
  extractPartialBatch,
  isUserRejectedSignature,
  type ExchangeErrorBody,
  type PartialBatch,
} from './sdk.js';
import type { OrderStatusParseOptions } from './statuses.js';
import { describeTransportFailure, type TransportFailureInfo } from './transport.js';

/** Normalized view of an error thrown by an exchange call. */
export type ExchangeFailure =
  /**
   * A batch where some elements were rejected. Other elements MAY BE LIVE: record
   * `batch.placedOids`, and handle each result on its own.
   */
  | { readonly type: 'partial'; readonly message: string; readonly batch: PartialBatch }
  /** The exchange rejected the action (`status: 'err'` or a TWAP error). Nothing was applied. */
  | {
      readonly type: 'rejected';
      readonly message: string;
      readonly kind: ExchangeErrorKind;
      readonly body: ExchangeErrorBody;
    }
  /** SDK parameter validation failed before signing; nothing was sent and no nonce was used. */
  | { readonly type: 'validation'; readonly message: string }
  /** The wallet did not sign; nothing was sent. `userRejected` when a human declined. */
  | { readonly type: 'signing'; readonly message: string; readonly userRejected: boolean }
  /** Transport-level failure or anything unrecognized; see `info.outcome`. */
  | { readonly type: 'transport'; readonly message: string; readonly info: TransportFailureInfo };

/**
 * Turns anything thrown by an `ExchangeClient` call (or a custom transport) into an
 * {@link ExchangeFailure}:
 *
 * - `ApiRequestError` with per-element `statuses` -> `partial` (the SDK throws even when neighbours
 *   were placed; their oids are recovered);
 * - `ApiRequestError` with `status: 'err'` or a TWAP `data.status.error` -> `rejected` with a
 *   classified `kind`; any other `ApiRequestError` body proves nothing and becomes `transport` /
 *   `unknown`;
 * - `ValidationError` -> `validation`; `AbstractWalletError` -> `signing`;
 * - everything else -> `transport` with `info.outcome` `'not-applied'` or `'unknown'`.
 *
 * Unrecognized errors are `transport` / `unknown`: never assume nothing was placed.
 */
export function interpretExchangeError(err: unknown, opts: OrderStatusParseOptions = {}): ExchangeFailure {
  const batch = extractPartialBatch(err, opts);
  if (batch) return { type: 'partial', message: errorMessage(err), batch };
  const body = exchangeErrorBody(err);
  if (body) {
    const message = errorMessage(err);
    // `rejected` promises "nothing was applied", so it needs a body that proves it: `status: 'err'`
    // or a TWAP-style `data.status.error`. Any other ApiRequestError body falls through to the
    // transport classification, which reports it as outcome unknown.
    const inner = rejectionText(body);
    if (body.status === 'err' || inner !== undefined) {
      const text = inner ?? message;
      return { type: 'rejected', message: text, kind: classifyExchangeError(text), body };
    }
  }
  const info = describeTransportFailure(err);
  if (info.reason === 'validation') return { type: 'validation', message: info.message };
  if (info.reason === 'signing') {
    return { type: 'signing', message: info.message, userRejected: isUserRejectedSignature(err) };
  }
  return { type: 'transport', message: info.message, info };
}

function errorMessage(err: unknown): string {
  if (typeof err === 'object' && err !== null && typeof (err as { message?: unknown }).message === 'string') {
    return (err as { message: string }).message;
  }
  return String(err);
}

function rejectionText(body: ExchangeErrorBody): string | undefined {
  if (body.status === 'err') return typeof body.response === 'string' ? body.response : undefined;
  const data = (body.response as { data?: { status?: { error?: unknown } } } | undefined)?.data;
  const error = data?.status?.error;
  return typeof error === 'string' ? error : undefined;
}

/** What to do next after a failed exchange call. */
export type RetryAdvice =
  /** Re-send the same request (through the limiter, with backoff). */
  | 'retry'
  /** Do NOT re-send: the outcome is unknown or partial. Reconcile (cloid / openOrders / position) first. */
  | 'reconcile'
  /** Do not re-send; the failure repeats until inputs or state change. */
  | 'give-up';

/**
 * Retry policy by outcome and action class.
 *
 * `idempotent` must be `true` only for actions whose repetition cannot change the result: info reads,
 * cancels, `updateLeverage`, `agentEnableDexAbstraction`, a FULL reduce-only close. Opening and
 * increasing orders, partial reduce-only orders, TP/SL pairs and transfers are NOT idempotent: a
 * retry after "success with a timeout" doubles the entry, reduces twice or pays twice. Only a FULL
 * reduce-only close is idempotent: marking every reduce-only order idempotent lets a partial reduce
 * run twice.
 *
 * - not applied and transient (HTTP 429, "... and retry", WebSocket frame never sent) -> `retry` for
 *   any action;
 * - unknown and transient (5xx, 408, timeout, network, unreadable 200) -> `retry` if idempotent,
 *   otherwise `reconcile`;
 * - unknown and not transient (caller abort, partial batch, unrecognized) -> `reconcile`;
 * - everything else (validation, signing, 4xx, exchange rejections) -> `give-up` (for
 *   `postOnlyWouldCross` / `iocNoMatch` re-plan on the next tick instead of re-sending as is).
 */
export function recommendRetry(err: unknown, opts: { idempotent: boolean }): RetryAdvice {
  const failure = interpretExchangeError(err);
  switch (failure.type) {
    case 'partial':
      return 'reconcile';
    case 'rejected':
      return errorKindInfo(failure.kind).disposition === 'retryable' ? 'retry' : 'give-up';
    case 'validation':
    case 'signing':
      return 'give-up';
    case 'transport': {
      const { outcome, transient } = failure.info;
      if (outcome === 'not-applied') return transient ? 'retry' : 'give-up';
      if (!transient) return 'reconcile';
      return opts.idempotent ? 'retry' : 'reconcile';
    }
  }
}
All files