hyperliquid: unWatchOHLCV ack rejects a subscription that was re-armed in the meantime (watch -> unwatch -> watch raises UnsubscribeError)
Operating System
No response
Programming Language
Python
CCXT Version
4.5.78
Description
Environment
- ccxt 4.5.76 (freqtrade 2026.8 docker image, python 3.14.7) and 4.5.78 (latest release, verified today) — also present in current
master - exchange:
hyperliquid(USDC perps),ccxt.pro - public market data only — no API keys involved
- reproducer attached below, runs in ~30 s
Summary
Subscribing, unsubscribing and then re-subscribing to the same symbol/timeframe kills the new subscription if the re-subscribe happens before the unsubscribe acknowledgement arrives:
watch_ohlcv()raisesUnsubscribeErrorimmediately after the ack, and- the stream never delivers a candle
This is not an exotic sequence. A long-running consumer produces it in normal operation (drop the
pair, then schedule it again a moment later) — e.g. freqtrade's websocket layer ends a watch task
when cleanup_expired() drops a pair, calls un_watch_ohlcv(), and re-schedules the same pair
seconds later.
Minimal reproducer
#!/usr/bin/env python3
"""Minimal reproducer for the ccxt.pro Hyperliquid unsubscribe/re-subscribe race.
The sequence reproduced here is the one freqtrade's websocket layer produces in
normal operation (freqtrade/exchange/exchange_ws.py):
watch_ohlcv(...) # task A is running
-> task A ends # e.g. cleanup_expired() dropped the pair
-> un_watch_ohlcv(...) # unsubscribe is sent (asynchronous)
-> watch_ohlcv(...) # same pair/timeframe is re-scheduled right away
Observed on current ccxt (4.5.76 and 4.5.78): the re-subscription dies with
`UnsubscribeError` and never delivers a candle, because the unsubscribe ack
rejects the future that was armed by the re-subscription.
Steps:
1. control: fresh subscription -> expect candles
2. control: unsubscribe fully acked, then re-subscribe -> expect candles
3. bug: unsubscribe in flight + immediate re-subscribe -> expect UnsubscribeError
4. recovery: subscribe once more -> expect candles
Exit code 0 = bug reproduced, 1 = not reproduced.
"""
import asyncio
import sys
import time
import ccxt
import ccxt.pro as ccxtpro
TIMEOUT = 30.0
CONTROL_PAIR = "BTC/USDC:USDC"
CONTROL2_PAIR = "ETH/USDC:USDC"
BUG_PAIR = "XRP/USDC:USDC"
TIMEFRAME = "5m"
async def watch_once(ex, pair, timeframe, label, results, timeout=TIMEOUT):
"""Await one watch_ohlcv call and record the outcome."""
start = time.time()
try:
data = await asyncio.wait_for(ex.watch_ohlcv(pair, timeframe), timeout)
results.append((label, "OK", f"{len(data)} candle(s)", time.time() - start))
return True
except asyncio.TimeoutError:
results.append((label, "TIMEOUT", f"no data within {timeout:.0f}s", time.time() - start))
except asyncio.CancelledError:
results.append((label, "CANCELLED", "", time.time() - start))
except Exception as e: # noqa: BLE001 - report whatever comes back
results.append((label, type(e).__name__, str(e)[:60], time.time() - start))
return False
async def main():
print(f"python : {sys.version.split()[0]}")
print(f"ccxt : {ccxt.__version__}")
print(f"exchange: hyperliquid (USDC perps), timeframe {TIMEFRAME}")
print()
ex = ccxtpro.hyperliquid({"options": {"defaultType": "swap"}, "enableRateLimit": True})
await ex.load_markets()
print(f"markets: {len(ex.markets)} loaded")
print()
results = []
try:
# 1 - control: a fresh subscription works
await watch_once(ex, CONTROL_PAIR, TIMEFRAME, "1 control: fresh subscribe", results)
# 2 - control: unsubscribe fully acked, then re-subscribe
await watch_once(ex, CONTROL2_PAIR, TIMEFRAME, "2 control: watch", results)
acked = False
try:
await asyncio.wait_for(ex.un_watch_ohlcv(CONTROL2_PAIR, TIMEFRAME), TIMEOUT)
acked = True
except Exception as e: # noqa: BLE001
results.append(("2 control: unwatch", type(e).__name__, str(e)[:60], 0.0))
if acked:
results.append(("2 control: unwatch", "ACKED", "", 0.0))
await watch_once(
ex, CONTROL2_PAIR, TIMEFRAME, "2 control: re-subscribe", results
)
# 3 - bug: unsubscribe in flight + immediate re-subscribe
await watch_once(ex, BUG_PAIR, TIMEFRAME, "3 bug: first watch", results)
unwatch_task = asyncio.create_task(ex.un_watch_ohlcv(BUG_PAIR, TIMEFRAME))
await asyncio.sleep(0.1) # the ack has not arrived yet - we re-subscribe now
await watch_once(ex, BUG_PAIR, TIMEFRAME, "3 bug: re-subscribe", results)
unwatch_task.cancel()
# 4 - recovery: subscribe once more
await watch_once(ex, BUG_PAIR, TIMEFRAME, "4 recovery: subscribe again", results)
finally:
try:
await ex.close()
except Exception: # noqa: BLE001
pass
print(f"{'step':<32} {'result':<18} {'detail':<24} {'secs':>5}")
print("-" * 84)
for label, outcome, detail, secs in results:
print(f"{label:<32} {outcome:<18} {detail:<24} {secs:>5.1f}")
print()
bug_step = next((r for r in results if r[0] == "3 bug: re-subscribe"), None)
reproduced = bool(bug_step and bug_step[1] == "UnsubscribeError")
if reproduced:
print("BUG REPRODUCED: the re-subscription was rejected with UnsubscribeError.")
return 0
print(f"NOT REPRODUCED: '3 bug: re-subscribe' returned {bug_step[1] if bug_step else 'n/a'}.")
return 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))Output (ccxt 4.5.76):
python : 3.14.7
ccxt : 4.5.76
exchange: hyperliquid (USDC perps), timeframe 5m
markets: 812 loaded
step result detail secs
------------------------------------------------------------------------------------
1 control: fresh subscribe OK 1 candle(s) 0.9
2 control: watch OK 1 candle(s) 2.7
2 control: unwatch ACKED 0.0
2 control: re-subscribe OK 1 candle(s) 0.8
3 bug: first watch OK 1 candle(s) 9.0
3 bug: re-subscribe UnsubscribeError hyperliquid candles:5m:XRP/USDC:USDC 0.1
4 recovery: subscribe again OK 1 candle(s) 1.3
BUG REPRODUCED: the re-subscription was rejected with UnsubscribeError.Both controls pass (a fresh subscribe works; unwatch → wait for ack → subscribe works), only the overlapping sequence fails. Reproduced on 3 consecutive runs, on 4.5.76 and 4.5.78.
Analysis
Two mechanisms combine:
The ack rejects the re-armed future.
cleanUnsubscription()deletes the subscription bookkeeping and then rejects whatever future is currently registered undercandles:<tf>:<symbol>:ts/src/base/Exchange.ts:9047-9058(cleanUnsubscription, rejection at 9055-9058)python/ccxt/base/exchange.py:7746-7754
With the sequence above, that future belongs to the new subscription, so the caller's
watch_ohlcv()await rejects withUnsubscribeError.The hyperliquid handler calling it:
ts/src/pro/hyperliquid.ts:1596-1610(handleOHLCVUnsubscription) /python/ccxt/pro/hyperliquid.py:1469-1480.The re-subscribe never sends a
subscribemessage.watch()only sends the message when the subscription hash was not registered yet:ts/src/base/Exchange.ts:1885ff. (watch())python/ccxt/async_support/base/exchange.py:538-578(subscribed = client.subscriptions.get (subscribe_hash)/if not subscribed: connected.add_done_callback(after))
During the race,
client.subscriptions['candles:<tf>:<symbol>']is still present — it is only removed by the same ack — so the re-subscribe is silently deduplicated and nothing is sent.
So after the race the stream is doubly dead: no subscribe was sent and the waiting future is rejected. A subsequent call succeeds again (step 4 in the reproducer), because by then the ack has cleaned up the bookkeeping.
Note: this is not the same shape as the Bitget case fixed in #27105. There, the guard
if (subMessageHash in client.futures) was enough because rejecting a non-existent waiter was the
bug. Here a waiter does exist — it is just the wrong one to reject.
Downstream impact (freqtrade, for context)
freqtrade enables websockets for hyperliquid (ws_enabled), so this sequence happens routinely. In
a 10.5 h dry run (57 pairs × 5 timeframes):
- 0
watch donelog lines — not a single candle ever arrived over the websocket - ~3,000
Exception in continuously_async_watch_ohlcv ... UnsubscribeErrorerrors with tracebacks - ~2,190 watch tasks ending (
Task finished) — the retry/re-schedule loop - every candle then had to be fetched via REST, which pushed the exchange's per-IP weight limit (1200/min) into 429 responses, and 5m data ended up 10–20 minutes stale
The task dying is what makes it self-sustaining: the death triggers another unwatch, whose ack rejects the next subscription, and so on.
Workaround used downstream (explicitly not a fix proposal)
We patched un_watch_ohlcv() to a no-op for hyperliquid locally to keep the bot running (no
unsubscribe → no ack → no rejection). It works, but it leaks subscriptions, so I am not proposing
it as a fix — it is only mentioned as evidence that the failure lives in this path. We are also
hardening the consumer to retry on UnsubscribeError.
Proposed directions
Happy to prepare a PR for whichever direction you prefer; I can validate it against the reproducer above.
A. Exchange-scoped (no base changes, in the spirit of #27105). In
handleOHLCVUnsubscription(), do the bookkeeping but skip the rejection when a waiter is still
registered, and re-issue the subscribe for it — the subscription was re-armed while the unsubscribe
was in flight, so the stream should simply continue:
handleOHLCVUnsubscription (client: Client, subscription: Dict) {
const coin = this.safeString (subscription, 'coin');
const marketId = this.coinToMarketId (coin);
const symbol = this.safeSymbol (marketId);
const interval = this.safeString (subscription, 'interval');
const timeframe = this.findTimeframe (interval);
const subMessageHash = 'candles:' + timeframe + ':' + symbol;
const messageHash = 'unsubscribe:' + subMessageHash;
const rearmed = (subMessageHash in client.futures); // (re)subscribed while unsubscribe was in flight
if (rearmed) {
// clean bookkeeping without rejecting the (new) waiter
if (messageHash in client.subscriptions) { delete client.subscriptions[messageHash]; }
if (subMessageHash in client.subscriptions) { delete client.subscriptions[subMessageHash]; }
client.resolve (true, messageHash);
this.spawn (this.watchOHLCV, symbol, timeframe); // re-send subscribe (subscription marker was cleared above)
} else {
this.cleanUnsubscription (client, subMessageHash, messageHash);
}
// ... ohlcvs cleanup as before
}(Sketch only — not tested; spawned via this.spawn() per ccxt conventions. It touches only
ts/src/pro/hyperliquid.ts, a derived exchange class, so it transpiles automatically and needs no
hand-written mirrors — unlike option B below.)
Trade-off: a caller that unwatches while still awaiting its watch future would no longer receive
UnsubscribeError, it would keep receiving candles instead. That is arguably the friendlier
behaviour, but it is a semantic change and your call.
B. Base-scoped. Keep the rejection semantics, but serialize per hash: track in-flight unsubscribes and, when a subscribe arrives for the same hash while the ack is outstanding, defer it and send the subscribe once the ack resolves (or skip the unsubscribe entirely if a new consumer appeared). More robust for all exchanges, but per CONTRIBUTING it needs the hand-written python/php/cs/go/java mirrors in the same PR.
Source: ccxt/ccxt