SPB Git

Repository Public

http.ts 7.0 KB 107 lines typescript

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
Files main
Menu

Theme