Skip to content
markpaper

src/transport/weights.ts

v0.3.0 · 5.4 KB

Download file
// Request weights for the Hyperliquid per-IP REST budget.
//
// Hyperliquid meters REST traffic per egress IP (~1200 weight/min, /info and
// /exchange combined). Under-counting a weight in a local limiter is the classic
// way to get 429 storms while "staying under budget": a common mistake is to count
// `openOrders` / `frontendOpenOrders` / `extraAgents` / `portfolio` as 2 instead
// of 20, which under-counts them 10x.

import type { InfoRequest } from './types.js';

/** Weight of any documented /info request that is not listed in {@link INFO_WEIGHTS}. */
export const DEFAULT_INFO_WEIGHT = 20;

/**
 * Known /info weights by `type` (Hyperliquid rate-limit docs).
 *
 * - 2: `l2Book`, `allMids`, `clearinghouseState`, `spotClearinghouseState`, `orderStatus`.
 * - 60: `userRole` — expensive, call it once at startup.
 * - 20: everything else, listed explicitly where people used to get it wrong.
 *
 * Unknown types fall back to {@link DEFAULT_INFO_WEIGHT} (20): over-counting only slows a bot down,
 * under-counting produces 429s.
 */
export const INFO_WEIGHTS: Readonly<Record<string, number>> = Object.freeze({
  l2Book: 2,
  allMids: 2,
  clearinghouseState: 2,
  spotClearinghouseState: 2,
  // Weight 2 comes from a single source; still far cheaper than userFills for status checks.
  orderStatus: 2,
  // Not in the docs' weight-2 list (checked 2026-09-13), although it is sometimes listed as 2. Counted
  // as 20 until measured: over-counting only slows the bucket, under-counting produces 429s.
  userDexAbstraction: 20,

  userRole: 60,

  meta: 20,
  metaAndAssetCtxs: 20,
  perpDexs: 20,
  spotMeta: 20,
  spotMetaAndAssetCtxs: 20,
  // Not 2: counting these as light under-counts them 10x.
  openOrders: 20,
  frontendOpenOrders: 20,
  extraAgents: 20,
  portfolio: 20,
  userNonFundingLedgerUpdates: 20,
  userFills: 20,
  userFillsByTime: 20,
  historicalOrders: 20,
  candleSnapshot: 20,
  maxBuilderFee: 20,
  referral: 20,
  userFees: 20,
  userRateLimit: 20,
  userFunding: 20,
  fundingHistory: 20,
  recentTrades: 20,
  twapHistory: 20,
  userTwapSliceFills: 20,
});

/** Largest weight in {@link INFO_WEIGHTS}; a limiter bucket must hold at least this many tokens. */
export const MAX_INFO_WEIGHT = 60;

/**
 * Base IP weight of a POST /info body, derived from its `type`.
 *
 * Uses an own-property lookup, so `type: 'constructor'` and friends get the default weight
 * instead of a prototype member.
 */
export function weightOf(body: Pick<InfoRequest, 'type'>): number {
  const type = body.type;
  if (typeof type === 'string' && Object.prototype.hasOwnProperty.call(INFO_WEIGHTS, type)) {
    return INFO_WEIGHTS[type] ?? DEFAULT_INFO_WEIGHT;
  }
  return DEFAULT_INFO_WEIGHT;
}

/**
 * Response-size surcharge rules: extra weight per `perItems` elements returned.
 *
 * @experimental Taken from the public Hyperliquid docs and not measured live (a flat 20 per request
 * is the common simplification). The exact surcharge (assumed: +1 per started block of
 * `perItems`) may differ. The rule errs on the conservative side — if HL charges less, the limiter
 * just throttles a bit more than necessary; if it charges more, budgets for backfills are still low.
 */
export const RESPONSE_WEIGHT_RULES: Readonly<Record<string, { perItems: number }>> = Object.freeze({
  userFills: { perItems: 20 },
  userFillsByTime: { perItems: 20 },
  historicalOrders: { perItems: 20 },
  userTwapSliceFills: { perItems: 20 },
  recentTrades: { perItems: 20 },
  fundingHistory: { perItems: 20 },
  userFunding: { perItems: 20 },
  userNonFundingLedgerUpdates: { perItems: 20 },
  twapHistory: { perItems: 20 },
  candleSnapshot: { perItems: 60 },
});

/**
 * Extra weight owed for a response, on top of the base weight paid before the request.
 *
 * A `userFillsByTime` page with 2 000 fills or a 5 000-candle `candleSnapshot` costs far more
 * than 20; a backfill must be budgeted by returned items, not by request count. The info client
 * charges this amount to the limiter after the response arrives.
 *
 * Returns 0 for types without a rule and for non-array responses.
 *
 * @experimental See {@link RESPONSE_WEIGHT_RULES}: documented by HL, not verified live.
 */
export function responseWeightSurcharge(type: string, response: unknown): number {
  if (!Object.prototype.hasOwnProperty.call(RESPONSE_WEIGHT_RULES, type)) return 0;
  const rule = RESPONSE_WEIGHT_RULES[type];
  if (!rule || !Array.isArray(response) || response.length === 0) return 0;
  return Math.ceil(response.length / rule.perItems);
}

/**
 * IP weight of one /exchange request carrying `nOrders` orders: `1 + floor(n / 40)`.
 *
 * Batching saves IP weight but NOT the per-address budget, where every order in the batch counts
 * (see {@link exchangeAddressRequests}).
 */
export function exchangeIpWeight(nOrders = 1): number {
  const n = Math.max(0, Math.floor(Number.isFinite(nOrders) ? nOrders : 0));
  return 1 + Math.floor(n / 40);
}

/** Address-budget requests consumed by an order batch: each order counts (a TP/SL pair = 2, `modify` = 1). */
export function exchangeAddressRequests(nOrders = 1): number {
  return Math.max(0, Math.floor(Number.isFinite(nOrders) ? nOrders : 0));
}

/**
 * Weight a limiter should reserve for one exchange action.
 *
 * Reserve 2 per action while the real IP weight is 1: the headroom absorbs batch surcharges and
 * the burst-window behaviour of HL. Reporting should use the real weight.
 */
export const EXCHANGE_RESERVE_WEIGHT = 2;
All files