Coverage for adrs/oms/rate_limit/hyperliquid_limiter.py: 94%

138 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-29 04:56 +0800

1""" 

2Rate limiting for Hyperliquid. 

3 

4Hyperliquid rate-limits on two independent axes: 

5 

6* an aggregated per-IP weight budget of 1200 per minute, which this limiter 

7 models exactly from a static cost table, and 

8* a cumulative per-address allowance of one request per USDC traded, with an 

9 initial buffer of 10,000 requests, which it does not. 

10 

11The second is not a rate. It is a lifetime allowance that grows with traded 

12volume, so mirroring it would mean polling volume (paying weight for the 

13privilege) and acting on an estimate that is wrong either way: throttling when 

14there was headroom, or failing to protect when there was not. It is enormous 

15once trading -- $1M of volume earns 1M requests -- and its failure mode is a 

16clean, detectable throttle to one request per 10s. So it is handled reactively 

17via HyperliquidErrorPolicy, and counted here only to warn as the initial buffer 

18depletes. Counted for observability; never enforced as a limit we cannot see. 

19""" 

20 

21import asyncio 

22import logging 

23import time 

24from collections import deque 

25from contextlib import asynccontextmanager 

26from typing import Any, AsyncGenerator, TYPE_CHECKING 

27 

28from cybotrade.exceptions import DeserializationError 

29from cybotrade.hyperliquid import HyperliquidClient, HyperliquidError 

30 

31from adrs.oms.rate_limit.error_policy import is_hyperliquid_rate_limit_error 

32from adrs.oms.rate_limit.exchange_limit_profiles import ( 

33 Endpoints, 

34 HYPERLIQUID_ADDRESS_ACTION_BUFFER, 

35 HYPERLIQUID_COSTS, 

36 HYPERLIQUID_IP_WEIGHT_PER_MINUTE, 

37 HYPERLIQUID_RATE_LIMIT_COOLDOWN_MS, 

38 HYPERLIQUID_WEIGHT_WINDOW_SEC, 

39 HyperliquidLimitProfile, 

40 HyperliquidRateLimitPool, 

41) 

42from adrs.oms.rate_limit.rate_limiter import LocalRateLimitError, RateLimiter 

43 

44if TYPE_CHECKING: 

45 from adrs.oms.config import ConfigManager 

46 

47logger = logging.getLogger(__name__) 

48 

49# Fraction of the initial address buffer at which to start warning. 

50_ACTION_WARN_RATIO = 0.8 

51 

52# Endpoints whose real cost is amortised over a window rather than charged per 

53# guard() call, because cybotrade serves the repeat calls from a client-side 

54# cache and they never reach Hyperliquid. See _effective_weight(). 

55_AMORTISED_ENDPOINTS = frozenset({Endpoints.GET_SYMBOL_INFO}) 

56 

57 

58class HyperliquidRateLimiter(RateLimiter): 

59 def __init__(self, config: "ConfigManager"): 

60 if not isinstance(config.exchange, HyperliquidClient): 

61 raise Exception("Exchange mismatch with rate limiter") 

62 super().__init__(config) 

63 

64 # Weight is per IP, so a shard behind one NAT address must divide it; 

65 # the address allowance is account-scoped and is not divided. This is 

66 # the distinction the RateLimiter base class documents. 

67 ceiling = int( 

68 HYPERLIQUID_IP_WEIGHT_PER_MINUTE 

69 * self.soft_limit_percentage 

70 / self.tenants_per_egress_ip 

71 ) 

72 self.limit_profile = HyperliquidLimitProfile( 

73 request_weight_limit_per_minute=ceiling, 

74 address_action_buffer=HYPERLIQUID_ADDRESS_ACTION_BUFFER, 

75 ) 

76 # (epoch_ms, weight) charged, trimmed to the trailing window 

77 self.weight_window: deque[tuple[int, int]] = deque() 

78 self.address_actions = 0 

79 self._warned_actions = False 

80 # epoch_ms of the last full charge for each amortised endpoint; see 

81 # _effective_weight(). None until the first charge in a fresh window. 

82 self._amortised_charged_at: dict[Endpoints, int] = {} 

83 

84 logger.info( 

85 f"[HYPERLIQUID_LIMITS] Weight(1m) {ceiling} (IP-scoped, split " 

86 f"{self.tenants_per_egress_ip} ways). Address actions counted " 

87 f"against a {HYPERLIQUID_ADDRESS_ACTION_BUFFER} buffer, not enforced." 

88 ) 

89 

90 # ---- clock --------------------------------------------------------- 

91 

92 async def init(self): 

93 """ 

94 Nothing to initialise. 

95 

96 Hyperliquid publishes no server-time endpoint and nonces are 

97 client-generated, so there is no clock to sync against. 

98 """ 

99 return None 

100 

101 def get_synced_time_ms(self) -> int: 

102 """Local clock; see init() for why there is nothing to sync to.""" 

103 return int(time.time() * 1000) 

104 

105 async def on_resync_time(self): 

106 """No-op, for the same reason as init().""" 

107 return None 

108 

109 # ---- window -------------------------------------------------------- 

110 

111 def current_weight(self) -> int: 

112 """Weight charged inside the trailing window, after trimming.""" 

113 self._trim_window() 

114 return sum(weight for _, weight in self.weight_window) 

115 

116 def _cost(self, endpoint: Endpoints) -> dict[str, int]: 

117 # KeyError rather than a zero default: an unpriced endpoint would be 

118 # charged nothing, and undercounting is what gets an IP banned. 

119 return HYPERLIQUID_COSTS[endpoint] 

120 

121 def _effective_weight(self, endpoint: Endpoints) -> int: 

122 """ 

123 Weight to charge for this call: the table price, except for an endpoint 

124 whose repeat calls cybotrade serves from its own cache. 

125 

126 GET_SYMBOL_INFO is the one such endpoint. config.py's 

127 update_symbol_info() takes one guard per symbol, but cybotrade's 

128 HyperliquidClient caches `metaAndAssetCtxs` (METADATA_TTL, 5 minutes) 

129 and only the first call in a refresh reaches the exchange -- so a 

130 20-symbol sweep issues exactly one real weight-20 request while the 

131 per-symbol charge bills 400 of the minute's 1200. On the documented 

132 14-tenant shard the ceiling is 68/min, which the phantom charge can 

133 never fit: the sweep never completes, _symbol_info_refreshed_at is 

134 never stamped, and every tick retries it -- a livelock that starves 

135 PLACE_ORDER and CANCEL_ORDER. The Binance table documents the same 

136 phantom-charge failure. 

137 

138 So the full weight is charged for the first call in a rolling window 

139 and 0 for the rest of that window, capping the charge at once per 

140 HYPERLIQUID_WEIGHT_WINDOW_SEC. 

141 

142 This is deliberately conservative rather than exact. It is coupled to 

143 cybotrade's HyperliquidClient.METADATA_TTL: at 5 minutes, at most one 

144 real call occurs per 5 minutes while this charges up to one per minute 

145 -- a 5x overcharge, in the safe direction. If that TTL were ever 

146 shortened below HYPERLIQUID_WEIGHT_WINDOW_SEC (60s), two real calls 

147 could land inside one window and this would undercount, which is the 

148 direction that gets the shared egress IP banned. Re-derive the cap 

149 against METADATA_TTL before assuming it still holds. 

150 """ 

151 weight = self._cost(endpoint)["weight"] 

152 if endpoint not in _AMORTISED_ENDPOINTS: 

153 return weight 

154 charged_at = self._amortised_charged_at.get(endpoint) 

155 if charged_at is None: 

156 return weight 

157 age_ms = self.get_synced_time_ms() - charged_at 

158 if 0 <= age_ms < HYPERLIQUID_WEIGHT_WINDOW_SEC * 1000: 

159 return 0 

160 return weight 

161 

162 def _trim_window(self): 

163 """Drop entries that have aged out of the trailing window.""" 

164 cutoff = self.get_synced_time_ms() - HYPERLIQUID_WEIGHT_WINDOW_SEC * 1000 

165 while self.weight_window and self.weight_window[0][0] <= cutoff: 

166 self.weight_window.popleft() 

167 

168 def reset_limits(self): 

169 """ 

170 Fully clear local state, as opposed to the passive time-based trimming 

171 _trim_window() does on every check. Public because the ABC requires it; 

172 nothing in adrs calls it today. 

173 

174 WARNING: clearing the window discards weight the exchange is still 

175 counting. Hyperliquid's budget is a trailing 60s window on its side and 

176 there is no exchange-side signal to resync from (see init()), so a call 

177 here does not make that spend go away -- it only makes this limiter 

178 blind to it, and the next 60s of calls are admitted on top of weight 

179 already spent. That is an over-admission into a budget shared by every 

180 tenant on the egress IP. In particular it must not be called to recover 

181 from a connection event: a reconnect tells us nothing about what the 

182 exchange has already counted. Contrast _trim_window(), which only drops 

183 entries that have genuinely aged out and is safe on every check. 

184 """ 

185 self.weight_window.clear() 

186 self.address_actions = 0 

187 self._warned_actions = False 

188 # Cleared too, so the next amortised call pays in full rather than 

189 # riding a window this reset just erased. 

190 self._amortised_charged_at.clear() 

191 

192 def _pool_key(self, endpoint: Endpoints) -> Any: 

193 # One aggregated budget, so every call contends for the same pool. 

194 return HyperliquidRateLimitPool.IP_WEIGHT 

195 

196 def _has_capacity(self, endpoint: Endpoints, **kwargs) -> bool: 

197 self._trim_window() 

198 # _effective_weight(), not the raw table price: record_usage() charges 

199 # the amortised figure, so projecting the raw one here would deny a 

200 # call that is about to cost nothing. 

201 projected = sum(w for _, w in self.weight_window) + self._effective_weight( 

202 endpoint 

203 ) 

204 ceiling = self.limit_profile.request_weight_limit_per_minute 

205 if projected > ceiling: 

206 logger.warning( 

207 f"[CHECK_LIMITS] Hyperliquid weight window is full: " 

208 f"{projected} > {ceiling}" 

209 ) 

210 return False 

211 return True 

212 

213 def _next_free_delay(self, endpoint: Endpoints) -> float: 

214 """Seconds until the oldest entry ages out and frees weight.""" 

215 self._trim_window() 

216 if self._has_capacity(endpoint): 

217 return 0.0 

218 if not self.weight_window: 

219 return 0.0 

220 oldest_ts = self.weight_window[0][0] 

221 expires_at = oldest_ts + HYPERLIQUID_WEIGHT_WINDOW_SEC * 1000 

222 return max(0.0, (expires_at - self.get_synced_time_ms()) / 1000.0) 

223 

224 def check_limits(self, endpoint: Endpoints, **kwargs) -> bool: 

225 """ 

226 Whether a guard() call may proceed: blocked while retry_after is 

227 active, and yields to anything queued in reserve() for this pool. 

228 """ 

229 # Absolute condition: while retry_after is active nothing proceeds. >= 

230 # rather than >, matching reserve() and both sibling limiters -- on the 

231 # exact millisecond of the deadline the hold still applies. 

232 if self.retry_after >= self.get_synced_time_ms(): 

233 logger.warning(f"[CHECK_LIMITS] cooling down until {self.retry_after}") 

234 return False 

235 # Yield to callers waiting in reserve() so reserved calls take priority. 

236 # Without this the four reserve() sites lose their whole mechanism: 

237 # guard() callers keep taking the capacity a queued reserver is waiting 

238 # for, so delta-critical position and open-order reads are starved. 

239 if self._waiters.get(self._pool_key(endpoint), 0) > 0: 

240 return False 

241 return self._has_capacity(endpoint, **kwargs) 

242 

243 def record_usage(self, endpoint: Endpoints, **kwargs): 

244 cost = self._cost(endpoint) 

245 now = self.get_synced_time_ms() 

246 weight = self._effective_weight(endpoint) 

247 if weight and endpoint in _AMORTISED_ENDPOINTS: 

248 # Opens the amortisation window for this endpoint; the rest of the 

249 # sweep rides it at 0. See _effective_weight(). 

250 self._amortised_charged_at[endpoint] = now 

251 self.weight_window.append((now, weight)) 

252 if cost["actions"]: 

253 self.address_actions += cost["actions"] 

254 self._maybe_warn_actions() 

255 

256 def _maybe_warn_actions(self) -> None: 

257 buffer = self.limit_profile.address_action_buffer 

258 if self._warned_actions or self.address_actions < buffer * _ACTION_WARN_RATIO: 

259 return 

260 self._warned_actions = True 

261 logger.warning( 

262 f"[HYPERLIQUID_LIMITS] {self.address_actions} exchange actions sent " 

263 f"against an initial address buffer of {buffer}. The real allowance " 

264 f"grows with traded volume and is not observable here; if the " 

265 f"exchange starts throttling, that is why." 

266 ) 

267 

268 # ---- guard --------------------------------------------------------- 

269 

270 @asynccontextmanager 

271 async def guard(self, endpoint: Endpoints) -> AsyncGenerator[None, None]: 

272 if not self.check_limits(endpoint=endpoint): 

273 raise LocalRateLimitError(f"Failed due to rate limits {self}") 

274 

275 self.record_usage(endpoint=endpoint) 

276 try: 

277 async with asyncio.timeout(self.call_timeout_sec): 

278 yield 

279 except BaseException as e: 

280 self._handle_call_error(e, endpoint) 

281 raise e 

282 else: 

283 self._on_call_success(endpoint) 

284 

285 # ---- error handling ----------------------------------------------- 

286 

287 def _handle_call_error( 

288 self, e: BaseException, endpoint: Endpoints | None = None 

289 ) -> None: 

290 """ 

291 Arm the local cooldown when Hyperliquid is throttling us. 

292 

293 Delegates the whole decision to local_cache_error(), which is the 

294 ABC-designated place for folding a failure into local state. Keeping 

295 one classifier means the two entry points cannot drift apart, and it 

296 stops local_cache_error() being dead code reachable from nowhere. 

297 

298 Unlike the Bybit limiter there is no optimistic pre-call decrement to 

299 refund: the weight window records what was actually sent, and a request 

300 that failed still cost its weight at the exchange. 

301 """ 

302 # Hyperliquid sends no rate-limit headers at all, so there is nothing 

303 # to pass; the exception itself carries every signal there is. 

304 if isinstance(e, Exception): 

305 self.local_cache_error({}, error=e) 

306 

307 def local_cache_error(self, headers: dict[str, Any], **kwargs: Any) -> None: 

308 """ 

309 Fold a rate-limit failure into local state. 

310 

311 Hyperliquid returns no rate-limit headers at all -- an action failure 

312 arrives in the body of an HTTP 200 -- so `headers` is always empty here 

313 and the signal comes from the exception. Callers pass it as `error=...`, 

314 or as `message=...` when only the text is to hand. 

315 

316 Classification is delegated to is_hyperliquid_rate_limit_error() rather 

317 than re-matched here, so the needles live in exactly one place; that 

318 also picks up the HTTP 429 case, which has no Hyperliquid body to match 

319 against. 

320 """ 

321 error = kwargs.get("error") 

322 if error is None: 

323 message = str(kwargs.get("message") or "") 

324 if not message: 

325 return 

326 error = HyperliquidError(message) 

327 if not isinstance(error, Exception): 

328 return 

329 if isinstance(error, DeserializationError): 

330 # Deliberately broad. cybotrade's HyperliquidClient._post_info / 

331 # _post_exchange never look at the HTTP status: they hand the body 

332 # straight to json.loads, so a 429's non-JSON body (an edge/proxy 

333 # page, not a Hyperliquid payload) surfaces here as 

334 # DeserializationError and as nothing else. At this layer a 

335 # malformed response is therefore indistinguishable from a 429, so 

336 # it is treated as a suspected throttle. Holding briefly on a read 

337 # failure costs some latency; missing a real 429 means polling 

338 # through the throttle and renewing an IP ban that takes down every 

339 # tenant sharing the egress address. 

340 logger.warning( 

341 f"[HYPERLIQUID_LIMITS] undecodable response ({error}); treating " 

342 f"it as a suspected throttle, since a 429 body reaches us as " 

343 f"exactly this and nothing else." 

344 ) 

345 self._arm_throttle_cooldown() 

346 return 

347 if is_hyperliquid_rate_limit_error(error): 

348 self._arm_throttle_cooldown() 

349 

350 def _arm_throttle_cooldown(self) -> None: 

351 deadline = self.get_synced_time_ms() + HYPERLIQUID_RATE_LIMIT_COOLDOWN_MS 

352 self.retry_after = max(self.retry_after, deadline) 

353 logger.warning( 

354 f"[HYPERLIQUID_LIMITS] rate limited by the exchange; holding calls " 

355 f"until {self.retry_after}. Either axis can produce this: the " 

356 f"address allowance is cumulative and clears as traded volume " 

357 f"accrues, while an IP weight overrun clears as the window drains." 

358 ) 

359 

360 # ---- repr ---------------------------------------------------------- 

361 

362 def __repr__(self) -> str: 

363 retry = "" 

364 if self.retry_after > self.get_synced_time_ms(): 

365 retry = f" [RETRYING_AFTER: {self.retry_after}]" 

366 return ( 

367 f"HyperliquidRateLimiter(weight: {self.current_weight()}" 

368 f"/{self.limit_profile.request_weight_limit_per_minute}, " 

369 f"actions: {self.address_actions}" 

370 f"/{self.limit_profile.address_action_buffer}){retry}" 

371 )