11import asyncio
22import json
3+ from enum import StrEnum
4+
35import websockets
46from loguru import logger
5- from tenacity import retry , retry_if_exception_type , wait_exponential
7+ from tenacity import retry , retry_if_exception_type , wait_fixed , stop_after_attempt
68import time
79
810from pusher .config import Config , STALE_TIMEOUT_SECONDS
1416HYPERLIQUID_MAINNET_WS_URL = "wss://api.hyperliquid.xyz/ws"
1517HYPERLIQUID_TESTNET_WS_URL = "wss://api.hyperliquid-testnet.xyz/ws"
1618
19+ class HLChannel (StrEnum ):
20+ """ Hyperliquid websocket subscription channels. See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions """
21+
22+ # activeAssetCtx includes oracle and mark price for perps (either main HyperCore or HIP-3)
23+ CHANNEL_ACTIVE_ASSET_CTX = "activeAssetCtx"
24+ # HL market mid price
25+ CHANNEL_ALL_MIDS = "allMids"
26+ # either subscription ack or error
27+ CHANNEL_SUBSCRIPTION_RESPONSE = "subscriptionResponse"
28+ # application-level ping response
29+ CHANNEL_PONG = "pong"
30+ # error response
31+ CHANNEL_ERROR = "error"
32+
33+ DATA_CHANNELS = [HLChannel .CHANNEL_ACTIVE_ASSET_CTX , HLChannel .CHANNEL_ALL_MIDS ]
34+
1735
1836class HyperliquidListener :
1937 """
@@ -27,6 +45,8 @@ def __init__(self, config: Config, hl_oracle_state: PriceSourceState, hl_mark_st
2745 self .hl_oracle_state = hl_oracle_state
2846 self .hl_mark_state = hl_mark_state
2947 self .hl_mid_state = hl_mid_state
48+ self .ws_ping_interval = config .hyperliquid .ws_ping_interval
49+ self .stop_after_attempt = config .hyperliquid .stop_after_attempt
3050
3151 def get_subscribe_request (self , asset ):
3252 return {
@@ -37,13 +57,19 @@ def get_subscribe_request(self, asset):
3757 async def subscribe_all (self ):
3858 await asyncio .gather (* (self .subscribe_single (hyperliquid_ws_url ) for hyperliquid_ws_url in self .hyperliquid_ws_urls ))
3959
40- @retry (
41- retry = retry_if_exception_type ((StaleConnectionError , websockets .ConnectionClosed )),
42- wait = wait_exponential (multiplier = 1 , min = 1 , max = 10 ),
43- reraise = True ,
44- )
4560 async def subscribe_single (self , url ):
46- return await self .subscribe_single_inner (url )
61+ logger .info ("Starting Hyperliquid listener loop: {}" , url )
62+
63+ @retry (
64+ retry = retry_if_exception_type (Exception ),
65+ wait = wait_fixed (1 ),
66+ stop = stop_after_attempt (self .stop_after_attempt ),
67+ reraise = True ,
68+ )
69+ async def _run ():
70+ return await self .subscribe_single_inner (url )
71+
72+ return await _run ()
4773
4874 async def subscribe_single_inner (self , url ):
4975 async with websockets .connect (url ) as ws :
@@ -59,48 +85,69 @@ async def subscribe_single_inner(self, url):
5985 await ws .send (json .dumps (subscribe_all_mids_request ))
6086 logger .info ("Sent subscribe request for allMids for dex: {} to {}" , self .market_name , url )
6187
88+ now = time .time ()
89+ channel_last_message_timestamp = {channel : now for channel in HLChannel }
90+ last_ping_timestamp = now
91+
6292 # listen for updates
6393 while True :
6494 try :
6595 message = await asyncio .wait_for (ws .recv (), timeout = STALE_TIMEOUT_SECONDS )
6696 data = json .loads (message )
6797 channel = data .get ("channel" , None )
98+ now = time .time ()
6899 if not channel :
69100 logger .error ("No channel in message: {}" , data )
70- elif channel == "subscriptionResponse" :
71- logger .debug ("Received subscription response: {}" , data )
72- elif channel == "error" :
101+ elif channel == HLChannel . CHANNEL_SUBSCRIPTION_RESPONSE :
102+ logger .info ("Received subscription response: {}" , data )
103+ elif channel == HLChannel . CHANNEL_ERROR :
73104 logger .error ("Received Hyperliquid error response: {}" , data )
74- elif channel == "activeAssetCtx" :
75- self .parse_hyperliquid_active_asset_ctx_update (data )
76- elif channel == "allMids" :
77- self .parse_hyperliquid_all_mids_update (data )
105+ elif channel == HLChannel .CHANNEL_ACTIVE_ASSET_CTX :
106+ self .parse_hyperliquid_active_asset_ctx_update (data , now )
107+ channel_last_message_timestamp [channel ] = now
108+ elif channel == HLChannel .CHANNEL_ALL_MIDS :
109+ self .parse_hyperliquid_all_mids_update (data , now )
110+ channel_last_message_timestamp [channel ] = now
111+ elif channel == HLChannel .CHANNEL_PONG :
112+ logger .debug ("Received pong" )
78113 else :
79114 logger .error ("Received unknown channel: {}" , channel )
115+
116+ # check for stale channels
117+ for channel in DATA_CHANNELS :
118+ if now - channel_last_message_timestamp [channel ] > STALE_TIMEOUT_SECONDS :
119+ logger .warning ("HyperliquidLister: no messages in channel {} stale in {} seconds; reconnecting..." , channel , STALE_TIMEOUT_SECONDS )
120+ raise StaleConnectionError (f"No messages in channel { channel } in { STALE_TIMEOUT_SECONDS } seconds, reconnecting..." )
121+
122+ # ping if we need to
123+ if now - last_ping_timestamp > self .ws_ping_interval :
124+ await ws .send (json .dumps ({"method" : "ping" }))
125+ last_ping_timestamp = now
80126 except asyncio .TimeoutError :
81- raise StaleConnectionError (f"No messages in { STALE_TIMEOUT_SECONDS } seconds, reconnecting..." )
82- except websockets .ConnectionClosed :
127+ logger .warning ("HyperliquidListener: No messages overall in {} seconds, reconnecting..." , STALE_TIMEOUT_SECONDS )
128+ raise StaleConnectionError (f"No messages overall in { STALE_TIMEOUT_SECONDS } seconds, reconnecting..." )
129+ except websockets .ConnectionClosed as e :
130+ rc , rr = e .rcvd .code if e .rcvd else None , e .rcvd .reason if e .rcvd else None
131+ logger .warning ("HyperliquidListener: Websocket connection closed (code={} reason={}); reconnecting..." , rc , rr )
83132 raise
84133 except json .JSONDecodeError as e :
85- logger .error ("Failed to decode JSON message: {} error: {}" , message , e )
134+ logger .exception ("Failed to decode JSON message: {} error: {}" , message , repr ( e ) )
86135 except Exception as e :
87- logger .error ("Unexpected exception: {}" , e )
136+ logger .exception ("Unexpected exception: {}" , repr ( e ) )
88137
89- def parse_hyperliquid_active_asset_ctx_update (self , message ):
138+ def parse_hyperliquid_active_asset_ctx_update (self , message , now ):
90139 try :
91140 ctx = message ["data" ]["ctx" ]
92141 symbol = message ["data" ]["coin" ]
93- now = time .time ()
94142 self .hl_oracle_state .put (symbol , PriceUpdate (ctx ["oraclePx" ], now ))
95143 self .hl_mark_state .put (symbol , PriceUpdate (ctx ["markPx" ], now ))
96144 logger .debug ("activeAssetCtx symbol: {} oraclePx: {} markPx: {}" , symbol , ctx ["oraclePx" ], ctx ["markPx" ])
97145 except Exception as e :
98- logger .error ("parse_hyperliquid_active_asset_ctx_update error: message: {} e: {}" , message , e )
146+ logger .exception ("parse_hyperliquid_active_asset_ctx_update error: message: {} e: {}" , message , repr ( e ) )
99147
100- def parse_hyperliquid_all_mids_update (self , message ):
148+ def parse_hyperliquid_all_mids_update (self , message , now ):
101149 try :
102150 mids = message ["data" ]["mids" ]
103- now = time .time ()
104151 for mid in mids :
105152 self .hl_mid_state .put (mid , PriceUpdate (mids [mid ], now ))
106153 logger .debug ("allMids: {}" , mids )
0 commit comments