src/transport/exchange.ts
v0.3.0 · 3.3 KB
// 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),
});
}