Repository Public
1// HTTP client of the Hyperliquid adapter: POST /info and POST /exchange through an injectable fetch, with the documented IP rate2// limit accounted for locally so that the adapter refuses a request BEFORE the venue does.3// https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits4// "REST requests share an aggregated weight limit of 1200 per minute."5// exchange: 1 + floor(batch_length / 40) · info: 2 for l2Book, allMids, clearinghouseState, orderStatus,6// spotClearinghouseState, exchangeStatus · 60 for userRole · 20 for every other documented info request ·7// plus 1 per 20 items returned for recentTrades, historicalOrders, userFills, userFillsByTime, fundingHistory, userFunding …8// and 1 per 60 items returned for candleSnapshot.9import { VenueError } from './error.ts';10import type { HlNetwork } from './signing.ts';1112export const HL_API: Record<HlNetwork, string> = { mainnet: 'https://api.hyperliquid.xyz', testnet: 'https://api.hyperliquid-testnet.xyz' };13export const HL_WS: Record<HlNetwork, string> = { mainnet: 'wss://api.hyperliquid.xyz/ws', testnet: 'wss://api.hyperliquid-testnet.xyz/ws' };1415export const HL_WEIGHT_LIMIT_PER_MINUTE = 1200;16const WEIGHT_2 = new Set(['l2Book', 'allMids', 'clearinghouseState', 'orderStatus', 'spotClearinghouseState', 'exchangeStatus']);17const PER_20_ITEMS = new Set(['recentTrades', 'historicalOrders', 'userFills', 'userFillsByTime', 'fundingHistory', 'userFunding', 'nonUserFundingUpdates', 'twapHistory', 'userTwapSliceFills', 'userTwapSliceFillsByTime', 'delegatorHistory', 'delegatorRewards', 'validatorStats']);1819/** base weight of an info request, known before it is sent */20export function hlInfoWeight(type: string): number { return WEIGHT_2.has(type) ? 2 : type === 'userRole' ? 60 : 20; }21/** additional weight charged on the answer's length */22export function hlInfoExtraWeight(type: string, items: number): number {23 if (type === 'candleSnapshot') return Math.floor(items / 60);24 return PER_20_ITEMS.has(type) ? Math.floor(items / 20) : 0;25}26/** weight of an exchange request: `1 + floor(batch_length / 40)` */27export function hlExchangeWeight(batchLength: number): number { return 1 + Math.floor(batchLength / 40); }2829/** Sliding one-minute window of spent weight. Shared by every request of one adapter instance (one IP). */30export class HlWeightBudget {31 readonly limit: number;32 private readonly now: () => number;33 private spent: { at: number; weight: number }[] = [];34 constructor(limit = HL_WEIGHT_LIMIT_PER_MINUTE, now: () => number = Date.now) { this.limit = limit; this.now = now; }35 used(): number {36 const cut = this.now() - 60_000;37 while (this.spent.length && this.spent[0]!.at <= cut) this.spent.shift();38 return this.spent.reduce((n, s) => n + s.weight, 0);39 }40 /** ms until `weight` fits again (0 = it fits now) */41 retryAfterMs(weight: number): number {42 let used = this.used();43 if (used + weight <= this.limit) return 0;44 for (const s of this.spent) { used -= s.weight; if (used + weight <= this.limit) return Math.max(1, s.at + 60_000 - this.now()); }45 return 60_000;46 }47 /** reserves `weight` or throws `rate_limited` without sending anything */48 take(weight: number, what: string): void {49 const wait = this.retryAfterMs(weight);50 if (wait > 0) throw new VenueError('hyperliquid', 'rate_limited', `local weight budget exhausted (${this.used()}/${this.limit} per minute) before ${what}`, { retryAfterMs: wait, local: true });51 this.spent.push({ at: this.now(), weight });52 }53 /** charges weight that is only known afterwards (per-item surcharge); never throws */54 charge(weight: number): void { if (weight > 0) this.spent.push({ at: this.now(), weight }); }55 /** the venue said 429: consider the window full */56 exhaust(): void { this.charge(Math.max(0, this.limit - this.used())); }57}5859export interface HlHttpOptions { network: HlNetwork; fetch?: typeof fetch; now?: () => number; timeoutMs?: number; budget?: HlWeightBudget; maxBytes?: number; baseUrl?: string }6061export interface HlHttp {62 readonly network: HlNetwork; readonly baseUrl: string; readonly budget: HlWeightBudget;63 /** POST /info — returns the parsed JSON body (not yet validated) */64 info(body: { type: string } & Record<string, unknown>): Promise<unknown>;65 /** POST /exchange — returns the parsed JSON body (not yet validated) */66 exchange(body: Record<string, unknown>, batchLength: number): Promise<unknown>;67}6869const err = (code: ConstructorParameters<typeof VenueError>[1], message: string, detail: Record<string, unknown> = {}) => new VenueError('hyperliquid', code, message, detail);7071export function createHlHttp(o: HlHttpOptions): HlHttp {72 const fetchImpl = o.fetch ?? fetch, now = o.now ?? Date.now, timeoutMs = o.timeoutMs ?? 10_000, maxBytes = o.maxBytes ?? 8_000_000;73 const baseUrl = o.baseUrl ?? HL_API[o.network], budget = o.budget ?? new HlWeightBudget(HL_WEIGHT_LIMIT_PER_MINUTE, now);7475 const post = async (path: '/info' | '/exchange', body: unknown, what: string): Promise<unknown> => {76 let r: Response;77 try {78 r = await fetchImpl(`${baseUrl}${path}`, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });79 } catch (e) {80 const name = (e as Error).name;81 throw err('unavailable', `${what}: ${name === 'AbortError' || name === 'TimeoutError' ? `no answer within ${timeoutMs} ms` : `network error (${(e as Error).message?.slice(0, 120) ?? name})`}`, { timeout: name === 'AbortError' || name === 'TimeoutError' });82 }83 let text: string;84 try { text = await r.text(); } catch (e) { throw err('unavailable', `${what}: answer interrupted (${(e as Error).message?.slice(0, 120)})`); }85 if (r.status === 429) { budget.exhaust(); throw err('rate_limited', `${what}: venue rate limit (HTTP 429)`, { retryAfterMs: 60_000, body: text.slice(0, 200) }); }86 if (r.status >= 500) throw err('unavailable', `${what}: HTTP ${r.status} ${text.slice(0, 120)}`, { status: r.status });87 // 422 = "Failed to deserialize the JSON body into the target type": our request was malformed for the venue88 if (!r.ok) throw err(path === '/exchange' ? 'rejected' : 'bad_answer', `${what}: HTTP ${r.status} ${text.slice(0, 160)}`, { status: r.status, body: text.slice(0, 400) });89 if (text.length > maxBytes) throw err('bad_answer', `${what}: answer too large (${text.length} bytes)`);90 try { return JSON.parse(text); } catch { throw err('bad_answer', `${what}: answer is not JSON`, { body: text.slice(0, 200) }); }91 };9293 return {94 network: o.network, baseUrl, budget,95 async info(body) {96 budget.take(hlInfoWeight(body.type), `info ${body.type}`);97 const out = await post('/info', body, `info ${body.type}`);98 if (Array.isArray(out)) budget.charge(hlInfoExtraWeight(body.type, out.length));99 return out;100 },101 async exchange(body, batchLength) {102 budget.take(hlExchangeWeight(batchLength), 'exchange');103 return post('/exchange', body, 'exchange');104 },105 };106}107