#30419·ccxt

hyperliquid: unWatchOHLCV ack rejects a subscription that was re-armed in the meantime (watch -> unwatch -> watch raises UnsubscribeError)

Author: PitbullDigitalCreated Sep 14, 2026Updated Sep 14, 2026
Labelsbugws

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() raises UnsubscribeError immediately 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

python
#!/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:

  1. The ack rejects the re-armed future. cleanUnsubscription() deletes the subscription bookkeeping and then rejects whatever future is currently registered under candles:<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 with UnsubscribeError.

    The hyperliquid handler calling it: ts/src/pro/hyperliquid.ts:1596-1610 (handleOHLCVUnsubscription) / python/ccxt/pro/hyperliquid.py:1469-1480.

  2. The re-subscribe never sends a subscribe message. watch() only sends the message when the subscription hash was not registered yet:

    • ts/src/base/Exchange.ts:1885 ff. (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 done log lines — not a single candle ever arrived over the websocket
  • ~3,000 Exception in continuously_async_watch_ohlcv ... UnsubscribeError errors 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:

typescript
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.