-
Notifications
You must be signed in to change notification settings - Fork 316
Check for staleness in each Hyperliquid websocket channel #3289
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
72a777d
1e24b69
f7d7f13
10c6f14
0529c6a
d5d12a3
5f7f754
a30b42b
e690d57
23419c9
8f664de
7726113
ef20359
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| import asyncio | ||
| import json | ||
| from enum import StrEnum | ||
|
|
||
| import websockets | ||
| from loguru import logger | ||
| from tenacity import retry, retry_if_exception_type, wait_exponential | ||
| from tenacity import retry, retry_if_exception_type, wait_fixed, stop_after_attempt | ||
| import time | ||
|
|
||
| from pusher.config import Config, STALE_TIMEOUT_SECONDS | ||
|
|
@@ -14,6 +16,22 @@ | |
| HYPERLIQUID_MAINNET_WS_URL = "wss://api.hyperliquid.xyz/ws" | ||
| HYPERLIQUID_TESTNET_WS_URL = "wss://api.hyperliquid-testnet.xyz/ws" | ||
|
|
||
| class HLChannel(StrEnum): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add some comments explain what it is. |
||
| """ Hyperliquid websocket subscription channels. See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions """ | ||
|
|
||
| # activeAssetCtx includes oracle and mark price for perps (either main HyperCore or HIP-3) | ||
| CHANNEL_ACTIVE_ASSET_CTX = "activeAssetCtx" | ||
| # HL market mid price | ||
| CHANNEL_ALL_MIDS = "allMids" | ||
| # either subscription ack or error | ||
| CHANNEL_SUBSCRIPTION_RESPONSE = "subscriptionResponse" | ||
| # application-level ping response | ||
| CHANNEL_PONG = "pong" | ||
| # error response | ||
| CHANNEL_ERROR = "error" | ||
|
|
||
| DATA_CHANNELS = [HLChannel.CHANNEL_ACTIVE_ASSET_CTX, HLChannel.CHANNEL_ALL_MIDS] | ||
|
|
||
|
|
||
| class HyperliquidListener: | ||
| """ | ||
|
|
@@ -27,6 +45,8 @@ def __init__(self, config: Config, hl_oracle_state: PriceSourceState, hl_mark_st | |
| self.hl_oracle_state = hl_oracle_state | ||
| self.hl_mark_state = hl_mark_state | ||
| self.hl_mid_state = hl_mid_state | ||
| self.ws_ping_interval = config.hyperliquid.ws_ping_interval | ||
| self.stop_after_attempt = config.hyperliquid.stop_after_attempt | ||
|
|
||
| def get_subscribe_request(self, asset): | ||
| return { | ||
|
|
@@ -37,13 +57,19 @@ def get_subscribe_request(self, asset): | |
| async def subscribe_all(self): | ||
| await asyncio.gather(*(self.subscribe_single(hyperliquid_ws_url) for hyperliquid_ws_url in self.hyperliquid_ws_urls)) | ||
|
|
||
| @retry( | ||
| retry=retry_if_exception_type((StaleConnectionError, websockets.ConnectionClosed)), | ||
| wait=wait_exponential(multiplier=1, min=1, max=10), | ||
| reraise=True, | ||
| ) | ||
| async def subscribe_single(self, url): | ||
| return await self.subscribe_single_inner(url) | ||
| logger.info("Starting Hyperliquid listener loop: {}", url) | ||
|
|
||
| @retry( | ||
| retry=retry_if_exception_type(Exception), | ||
| wait=wait_fixed(1), | ||
| stop=stop_after_attempt(self.stop_after_attempt), | ||
| reraise=True, | ||
| ) | ||
| async def _run(): | ||
| return await self.subscribe_single_inner(url) | ||
|
|
||
| return await _run() | ||
|
|
||
| async def subscribe_single_inner(self, url): | ||
| async with websockets.connect(url) as ws: | ||
|
|
@@ -59,48 +85,69 @@ async def subscribe_single_inner(self, url): | |
| await ws.send(json.dumps(subscribe_all_mids_request)) | ||
| logger.info("Sent subscribe request for allMids for dex: {} to {}", self.market_name, url) | ||
|
|
||
| now = time.time() | ||
| channel_last_message_timestamp = {channel: now for channel in HLChannel} | ||
| last_ping_timestamp = now | ||
|
|
||
| # listen for updates | ||
| while True: | ||
| try: | ||
| message = await asyncio.wait_for(ws.recv(), timeout=STALE_TIMEOUT_SECONDS) | ||
| data = json.loads(message) | ||
| channel = data.get("channel", None) | ||
| now = time.time() | ||
| if not channel: | ||
| logger.error("No channel in message: {}", data) | ||
| elif channel == "subscriptionResponse": | ||
| logger.debug("Received subscription response: {}", data) | ||
| elif channel == "error": | ||
| elif channel == HLChannel.CHANNEL_SUBSCRIPTION_RESPONSE: | ||
| logger.info("Received subscription response: {}", data) | ||
| elif channel == HLChannel.CHANNEL_ERROR: | ||
| logger.error("Received Hyperliquid error response: {}", data) | ||
| elif channel == "activeAssetCtx": | ||
| self.parse_hyperliquid_active_asset_ctx_update(data) | ||
| elif channel == "allMids": | ||
| self.parse_hyperliquid_all_mids_update(data) | ||
| elif channel == HLChannel.CHANNEL_ACTIVE_ASSET_CTX: | ||
| self.parse_hyperliquid_active_asset_ctx_update(data, now) | ||
| channel_last_message_timestamp[channel] = now | ||
| elif channel == HLChannel.CHANNEL_ALL_MIDS: | ||
| self.parse_hyperliquid_all_mids_update(data, now) | ||
| channel_last_message_timestamp[channel] = now | ||
| elif channel == HLChannel.CHANNEL_PONG: | ||
| logger.debug("Received pong") | ||
| else: | ||
| logger.error("Received unknown channel: {}", channel) | ||
|
|
||
| # check for stale channels | ||
| for channel in DATA_CHANNELS: | ||
| if now - channel_last_message_timestamp[channel] > STALE_TIMEOUT_SECONDS: | ||
| logger.warning("HyperliquidLister: no messages in channel {} stale in {} seconds; reconnecting...", channel, STALE_TIMEOUT_SECONDS) | ||
| raise StaleConnectionError(f"No messages in channel {channel} in {STALE_TIMEOUT_SECONDS} seconds, reconnecting...") | ||
|
|
||
| # ping if we need to | ||
| if now - last_ping_timestamp > self.ws_ping_interval: | ||
| await ws.send(json.dumps({"method": "ping"})) | ||
| last_ping_timestamp = now | ||
| except asyncio.TimeoutError: | ||
| raise StaleConnectionError(f"No messages in {STALE_TIMEOUT_SECONDS} seconds, reconnecting...") | ||
| except websockets.ConnectionClosed: | ||
| logger.warning("HyperliquidListener: No messages overall in {} seconds, reconnecting...", STALE_TIMEOUT_SECONDS) | ||
| raise StaleConnectionError(f"No messages overall in {STALE_TIMEOUT_SECONDS} seconds, reconnecting...") | ||
| except websockets.ConnectionClosed as e: | ||
| rc, rr = e.rcvd.code if e.rcvd else None, e.rcvd.reason if e.rcvd else None | ||
| logger.warning("HyperliquidListener: Websocket connection closed (code={} reason={}); reconnecting...", rc, rr) | ||
| raise | ||
| except json.JSONDecodeError as e: | ||
| logger.error("Failed to decode JSON message: {} error: {}", message, e) | ||
| logger.exception("Failed to decode JSON message: {} error: {}", message, repr(e)) | ||
| except Exception as e: | ||
| logger.error("Unexpected exception: {}", e) | ||
| logger.exception("Unexpected exception: {}", repr(e)) | ||
|
|
||
| def parse_hyperliquid_active_asset_ctx_update(self, message): | ||
| def parse_hyperliquid_active_asset_ctx_update(self, message, now): | ||
| try: | ||
| ctx = message["data"]["ctx"] | ||
| symbol = message["data"]["coin"] | ||
| now = time.time() | ||
| self.hl_oracle_state.put(symbol, PriceUpdate(ctx["oraclePx"], now)) | ||
| self.hl_mark_state.put(symbol, PriceUpdate(ctx["markPx"], now)) | ||
| logger.debug("activeAssetCtx symbol: {} oraclePx: {} markPx: {}", symbol, ctx["oraclePx"], ctx["markPx"]) | ||
| except Exception as e: | ||
| logger.error("parse_hyperliquid_active_asset_ctx_update error: message: {} e: {}", message, e) | ||
| logger.exception("parse_hyperliquid_active_asset_ctx_update error: message: {} e: {}", message, repr(e)) | ||
|
|
||
| def parse_hyperliquid_all_mids_update(self, message): | ||
| def parse_hyperliquid_all_mids_update(self, message, now): | ||
| try: | ||
| mids = message["data"]["mids"] | ||
| now = time.time() | ||
| for mid in mids: | ||
| self.hl_mid_state.put(mid, PriceUpdate(mids[mid], now)) | ||
| logger.debug("allMids: {}", mids) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ | |
| from pusher.price_state import PriceState | ||
| from pusher.publisher import Publisher | ||
| from pusher.metrics import Metrics | ||
| from pusher.user_limit_listener import UserLimitListener | ||
|
|
||
|
|
||
| def load_config(): | ||
|
|
@@ -52,13 +53,15 @@ async def main(): | |
| lazer_listener = LazerListener(config, price_state.lazer_state) | ||
| hermes_listener = HermesListener(config, price_state.hermes_state) | ||
| seda_listener = SedaListener(config, price_state.seda_state) | ||
| user_limit_listener = UserLimitListener(config, metrics, publisher.user_limit_address) | ||
|
|
||
| await asyncio.gather( | ||
| publisher.run(), | ||
| hyperliquid_listener.subscribe_all(), | ||
| lazer_listener.subscribe_all(), | ||
| hermes_listener.subscribe_all(), | ||
| seda_listener.run(), | ||
| user_limit_listener.run(), | ||
|
Comment on lines
58
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the expected behavior if any of these tasks exit/raise? I guess the app crashes and we let k8s restart it?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct! |
||
| ) | ||
| logger.info("Exiting hip-3-pusher..") | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what does this mean?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, it's the frequency for calling the user rate limit Hyperliquid API call. It can be low frequency because we basically just want to identify low wallet ballance within a few days.