src/transport/weights.ts
v0.3.0 · 5.4 KB
// 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;