Skip to content
markpaper

src/transport/exchange.ts

v0.3.0 · 3.3 KB

Download file
// Routing signed /exchange calls (made by the SDK) through the shared per-IP limiter.

import { isRateLimitError, isTransientError } from './errors.js';
import { type WeightLimiter } from './limiter.js';
import { DEFAULT_RETRY, withRetry, type RetryOptions } from './retry.js';
import { EXCHANGE_RESERVE_WEIGHT, exchangeIpWeight } from './weights.js';

/** Options for {@link limitExchange}. */
export interface LimitExchangeOptions {
  /**
   * Whether repeating the action cannot change the outcome: reduce-only closes, `updateLeverage`,
   * enabling dex abstraction. Idempotent actions retry on 429, 408, 5xx, timeouts and network errors;
   * everything else (opening/increasing orders, TP/SL pairs, `usdSend`/`sendAsset`,
   * `reserveRequestWeight`) retries ONLY on 429.
   *
   * Why: a 429 is rejected before execution, but after a 5xx or timeout the order may already rest
   * on the book - a blind retry can double the position. Reconcile instead.
   */
  idempotent: boolean;
  /** Queue as `urgent` (reduce-only closes, protective orders) instead of `high`. */
  urgent?: boolean;
  /**
   * Weight to reserve per attempt. Default `max(EXCHANGE_RESERVE_WEIGHT, 1 + floor(nOrders / 40))`:
   * 2 for ordinary actions, more for large batches (a flat 2 under-counts a 200-order batch, whose
   * real IP weight is 6).
   */
  weight?: number;
  /** Orders in the batch, used for the default {@link weight}. Default 1. */
  nOrders?: number;
  /** Explicit limiter. It must be the same bucket as your info reads. */
  limiter: WeightLimiter;
  /** Retry policy; default 4 retries, 250 ms x 2.5^n, jitter +-40%. `false` disables retries. */
  retry?: RetryOptions | false;
  signal?: AbortSignal;
  /** Called before each retry sleep. */
  onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
}

/**
 * Runs an exchange action (typically an SDK `ExchangeClient` call) through the per-IP limiter with
 * a retry policy chosen by action class. Exchange requests count against the same IP budget as
 * /info: order fan-out that bypasses the bucket starves monitoring and itself.
 *
 * The SDK (`@nktkas/hyperliquid` 0.33) does not retry by itself; its `HttpRequestError` exposes
 * `response.status`, which the classifiers read. Note that an SDK `ApiRequestError` (the exchange
 * answered `status: 'err'` or per-order errors) is not retried here - with a partially accepted
 * batch some orders may already rest.
 *
 * @example
 * ```ts
 * const reduceOnly = params.orders.every((o) => o.r);
 * await limitExchange(() => exchange.order(params), { idempotent: reduceOnly, urgent: reduceOnly, limiter });
 * ```
 */
export function limitExchange<T>(fn: () => Promise<T>, options: LimitExchangeOptions): Promise<T> {
  if (!options || !options.limiter) throw new TypeError('limitExchange requires an explicit limiter');
  const limiter = options.limiter;
  const weight = options.weight ?? Math.max(EXCHANGE_RESERVE_WEIGHT, exchangeIpWeight(options.nOrders ?? 1));
  const priority = options.urgent ? 'urgent' : 'high';
  const run = () => limiter.schedule(weight, fn, { priority, signal: options.signal });
  if (options.retry === false) return run();
  return withRetry(run, {
    ...DEFAULT_RETRY,
    ...(options.retry ?? {}),
    signal: options.signal,
    onRetry: options.onRetry,
    shouldRetry: options.idempotent ? (err) => isTransientError(err) : (err) => isRateLimitError(err),
  });
}
All files