Skip to content
markpaper

src/transport/interpret.ts

v0.2.0 · 7.8 KB

Download file
// "An execute threw - what happened and what now?" One entry point over the error classes.
//
// Error codes (knowledge base, `orders.md` §11):
//   2020 OrderNotFound             - already cancelled OR filled (documentation)
//   2064 reduce-only oversized     - presumably "reduce-only would increase the position" (never observed)
//   2067 resting reduce-only       - reduce-only only with IOC/FOK (observed live)
//   2069 trading blocked           - trading_status = not_tradable (observed live, see markets/status.ts)
//   2117 post-only market          - only POST_ONLY accepted (observed live, see markets/status.ts)

import { isRateLimitError, isTransientError, NadoRejection } from './errors.js';

/** Known `error_code` values. */
export const NADO_ERROR_CODES = Object.freeze({
  /** `OrderNotFound`: already cancelled or filled. By documentation. */
  ORDER_NOT_FOUND: 2020,
  /** Reduce-only larger than the position / would increase it. Semantics not confirmed. @experimental */
  REDUCE_ONLY_OVERSIZED: 2064,
  /** Resting reduce-only is not allowed (taker types only). Observed live. */
  RESTING_REDUCE_ONLY: 2067,
  /** `Trading is blocked for this market` (`not_tradable`). Observed live. */
  MARKET_NOT_TRADABLE: 2069,
  /** `Market is in post-only mode ... Only post-only orders are accepted`. Observed live. */
  MARKET_POST_ONLY: 2117,
});

/** Classification of a failed execute. */
export type ExecuteFailureKind =
  /** 2117: the market accepts only POST_ONLY right now (pre-listing, stock perps on weekends). */
  | 'postOnlyMarket'
  /** 2069: the market accepts nothing (`not_tradable`). */
  | 'marketBlocked'
  /** 2067: reduce-only on a resting type. Fix the order type; never resend as is. */
  | 'restingReduceOnly'
  /** 2064: reduce-only larger than the position (unconfirmed semantics). */
  | 'reduceOnlyOversized'
  /** 2020: the order to cancel is gone - cancelled earlier or FILLED. Cancel not confirmed. */
  | 'orderNotFound'
  /** Any other failure envelope: final, not applied, log it whole. */
  | 'rejected'
  /** HTTP 429: rejected before the matching engine. */
  | 'rateLimited'
  /** 5xx / timeout / network / unreadable body: outcome unknown. */
  | 'transportUnknown'
  /** Other 4xx, validation, caller abort: not applied. */
  | 'notApplied';

/** What to do next. */
export type ExecuteAdvice =
  /** Re-send the same request through the throttle with backoff. */
  | 'retry'
  /** Do NOT re-send. For a failure envelope the venue answered; for 2069 wait for the next tick. */
  | 'do-not-retry'
  /** Resting DEFAULT refused with 2117: re-send the SAME order as POST_ONLY with a new nonce (no duplicate: the first was never applied). */
  | 'resend-as-post-only'
  /** Full close refused with 2064: retry ONCE with the exact position size; alert if refused again. */
  | 'retry-exact-position-size'
  /** Outcome unknown or cancel unconfirmed: re-read positions / orders before doing anything else. */
  | 'reconcile';

/** Context of the failed action, used to refine the advice. */
export interface ExecuteContext {
  /** `'place'` (default), `'cancel'` or `'link'`. */
  action?: 'place' | 'cancel' | 'link';
  /** Order type that was sent (for `place`). */
  orderType?: 'default' | 'ioc' | 'fok' | 'post_only';
  /** True for a FULL reduce-only close. */
  fullClose?: boolean;
  /** True when a repeat cannot change the outcome (cancel, full close). */
  idempotent?: boolean;
}

/** Normalized failure. */
export interface ExecuteFailure {
  readonly kind: ExecuteFailureKind;
  /** `error_code` for a failure envelope. */
  readonly code: number | undefined;
  readonly message: string;
  /** `'not-applied'`: the venue did not apply the action. `'unknown'`: it may have. */
  readonly outcome: 'not-applied' | 'unknown';
  readonly advice: ExecuteAdvice;
  /** True for a failure envelope (the sequencer answered). */
  readonly rejection: boolean;
}

function messageOf(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);
}

/**
 * Interprets an error thrown by `client.execute(...)` (or a raw `NadoRejection`).
 *
 * - failure envelope -> `outcome: 'not-applied'`; `advice` by code: 2117 on a resting DEFAULT ->
 *   `resend-as-post-only` (an IOC gets `do-not-retry`: takers cannot exist on such a market and must not
 *   be rewritten); 2064 on a full close -> `retry-exact-position-size`; 2020 -> `reconcile` (the order
 *   may have filled); everything else -> `do-not-retry`;
 * - HTTP 429 -> `retry` (rejected before matching);
 * - 5xx / timeout / network / unparsable body -> `outcome: 'unknown'`; `retry` when idempotent, else `reconcile`;
 * - other 4xx / aborts / validation -> `not-applied`, `do-not-retry`.
 *
 * Unknown errors are `transportUnknown` / `reconcile`: never assume nothing was placed.
 */
export function interpretExecuteError(err: unknown, ctx: ExecuteContext = {}): ExecuteFailure {
  const action = ctx.action ?? 'place';
  if (err instanceof NadoRejection) {
    const base = { code: err.code, message: err.errorText, outcome: 'not-applied' as const, rejection: true };
    switch (err.code) {
      case NADO_ERROR_CODES.MARKET_POST_ONLY: {
        const resting = action === 'place' && ctx.orderType === 'default';
        return { ...base, kind: 'postOnlyMarket', advice: resting ? 'resend-as-post-only' : 'do-not-retry' };
      }
      case NADO_ERROR_CODES.MARKET_NOT_TRADABLE:
        return { ...base, kind: 'marketBlocked', advice: 'do-not-retry' };
      case NADO_ERROR_CODES.RESTING_REDUCE_ONLY:
        return { ...base, kind: 'restingReduceOnly', advice: 'do-not-retry' };
      case NADO_ERROR_CODES.REDUCE_ONLY_OVERSIZED:
        return {
          ...base,
          kind: 'reduceOnlyOversized',
          advice: ctx.fullClose ? 'retry-exact-position-size' : 'do-not-retry',
        };
      case NADO_ERROR_CODES.ORDER_NOT_FOUND:
        return { ...base, kind: 'orderNotFound', advice: 'reconcile' };
      default:
        return { ...base, kind: 'rejected', advice: 'do-not-retry' };
    }
  }
  const message = messageOf(err);
  if (isRateLimitError(err))
    return { kind: 'rateLimited', code: undefined, message, outcome: 'not-applied', advice: 'retry', rejection: false };
  if (isTransientError(err)) {
    return {
      kind: 'transportUnknown',
      code: undefined,
      message,
      outcome: 'unknown',
      advice: ctx.idempotent ? 'retry' : 'reconcile',
      rejection: false,
    };
  }
  if (typeof err === 'object' && err !== null && (err as { name?: unknown }).name === 'AbortError') {
    // The caller aborted: the request may or may not have left the socket.
    return {
      kind: 'transportUnknown',
      code: undefined,
      message,
      outcome: 'unknown',
      advice: 'reconcile',
      rejection: false,
    };
  }
  if (err instanceof RangeError || err instanceof TypeError) {
    return {
      kind: 'notApplied',
      code: undefined,
      message,
      outcome: 'not-applied',
      advice: 'do-not-retry',
      rejection: false,
    };
  }
  const status = (err as { status?: unknown } | null)?.status;
  if (typeof status === 'number' && status >= 400 && status <= 499) {
    return {
      kind: 'notApplied',
      code: undefined,
      message,
      outcome: 'not-applied',
      advice: 'do-not-retry',
      rejection: false,
    };
  }
  return {
    kind: 'transportUnknown',
    code: undefined,
    message,
    outcome: 'unknown',
    advice: 'reconcile',
    rejection: false,
  };
}

/**
 * True when a successful `place_order` response carries a usable digest. A "success" without one is
 * treated as a rejection: there is no digest to confirm or cancel the order by.
 */
export function hasUsableDigest(data: unknown): data is { digest: `0x${string}` } {
  const d = (data as { digest?: unknown } | null)?.digest;
  return typeof d === 'string' && /^0x[0-9a-fA-F]{64}$/.test(d);
}
All files