Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,31 +280,41 @@ Reservations are released when a subscription is unsubscribed or when its connec
when the server frees them too. Call `transport.close()` on a transport you are done with.

Pass your own `quota` when the default's assumption does not hold — a process behind several egress IPs needs one per
IP, and tests usually want isolation:
IP, and tests usually want isolation. Pass `rateLimit` to keep the default's [message pacing](#websocket-rate-limiting)
on the replacement; a `WebSocketQuota` constructed without it is accounting-only:

```ts
import { WebSocketQuota, WebSocketTransport } from "@bloxwap/hyperliquid";

const transport = new WebSocketTransport({ quota: new WebSocketQuota() });
const transport = new WebSocketTransport({ quota: new WebSocketQuota({ rateLimit: {} }) });
```

### WebSocket rate limiting

Outbound messages are budgeted but **not paced by default**. Opt into pacing when you approach the 2000/minute ceiling
— most easily by holding many subscriptions, since one reconnect re-subscribes all of them at once and 1000
subscriptions spend half the minute's budget instantly:

```ts
import { WebSocketQuota, WebSocketTransport } from "@bloxwap/hyperliquid";

const quota = new WebSocketQuota({ rateLimit: { capacity: 2000, refillPerMinute: 2000 } });
const transport = new WebSocketTransport({ quota });
```
Outbound messages are **paced by default**: the shared quota runs a token bucket sized to the server's budget (capacity
2000, refilling 2000/minute), because the default transport is exactly the one that trips the limit — one reconnect
re-subscribes every held subscription at once, so 1000 subscriptions spend half the minute's budget instantly, and a
flapping socket repeats the burst until the server refuses.

Pacing only ever delays `subscribe` and `unsubscribe` frames. **`post` requests and keep-alive pings never wait**: an
exchange action's wire order — and therefore per-wallet nonce ordering — depends on reaching the socket synchronously,
and delaying the keep-alive watchdog is how a half-open connection goes unnoticed. Both still *debit* the budget, so a
burst of orders correctly slows subscription traffic rather than silently overrunning the shared limit.

To opt out of pacing — or to resize the bucket — pass your own `quota`; constructed without `rateLimit`, it keeps the
subscription and unique-user guards but never delays a frame client-side:

```ts
import { WebSocketQuota, WebSocketTransport } from "@bloxwap/hyperliquid";

// Accounting only: nothing waits client-side.
const transport = new WebSocketTransport({ quota: new WebSocketQuota() });

// Or keep pacing with a custom burst size and refill rate.
const paced = new WebSocketTransport({
quota: new WebSocketQuota({ rateLimit: { capacity: 1000, refillPerMinute: 2000 } }),
});
```

As with [HTTP rate limiting](#rate-limiting), the budget is client-side bookkeeping: other processes, other machines
behind the same IP, and traffic the SDK cannot see all draw on the same server-side bucket.
22 changes: 21 additions & 1 deletion src/transport/_rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
* The WebSocket transport budgets a different quantity against the same machinery — outbound
* *messages*, capped at 2000/minute per IP across every connection — through
* `WebSocketQuota`. Both callers need the identical FIFO-with-debt semantics, so the
* bucket lives at the transport root rather than under either one.
* bucket lives at the transport root rather than under either one. `WebSocketQuota`
* additionally probes the bucket synchronously ({@linkcode TokenBucketRateLimiter.tryAcquire})
* so an uncontended send never pays for a promise it did not need.
* @module
*/

Expand Down Expand Up @@ -139,6 +141,24 @@ export class TokenBucketRateLimiter {
return promise;
}

/**
* Deducts `weight` tokens synchronously when that costs no queued acquisition its turn:
* after a refill, the deduction happens only when no waiter is pending and the bucket
* covers the cost. Returns whether the tokens were deducted.
*
* A pending FIFO always wins: even a bucket that covers `weight` refuses while any
* acquisition is queued, because serving the newcomer first would let a later arrival
* overtake an earlier one — exactly the reordering the queue in
* {@linkcode TokenBucketRateLimiter.acquire} exists to prevent. A refused caller falls
* back to `acquire` and takes its place at the tail.
*/
tryAcquire(weight: number): boolean {
this._refill();
if (this._head !== undefined || this._tokens < weight) return false;
this._tokens -= weight;
return true;
}

/**
* Debits `weight` tokens without waiting, possibly driving the bucket into debt.
*
Expand Down
67 changes: 52 additions & 15 deletions src/transport/websocket/_dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,11 @@
/**
* The per-IP outbound message budget, or `undefined` when this dispatcher is not budgeted.
*
* Hyperliquid caps messages sent at 2000/minute per IP across every connection, and the
* SDK can overrun it in one burst: at the 1000-subscription cap a single reconnect
* re-subscribes everything at once, spending half the minute's budget instantly, and a
* socket flapping under `maxRetries: Infinity` repeats that.
* Hyperliquid caps messages sent at 2000/minute per IP across every connection, and
* without pacing the SDK overruns it in one burst: at the 1000-subscription cap a single
* reconnect re-subscribes everything at once, spending half the minute's budget instantly,
* and a socket flapping under `maxRetries: Infinity` repeats that — which is why the
* shared default quota paces outbound messages (see `sharedWebSocketQuota` in `_quota.ts`).
*/
private readonly _quota: WebSocketQuota | undefined;

Expand Down Expand Up @@ -220,7 +221,7 @@
this._byEchoId.clear();
for (const entry of abandoned) {
entry.reject(
new WebSocketRequestError(

Check failure on line 224 in src/transport/websocket/_dispatcher.ts

View workflow job for this annotation

GitHub Actions / perf

WebSocketRequestError: WebSocket connection closed

at handleDisconnect (/home/runner/work/hyperliquid/hyperliquid/src/transport/websocket/_dispatcher.ts:224:11) at _dispatchClose (/home/runner/work/hyperliquid/hyperliquid/src/transport/websocket/_reconnectingSocket.ts:539:12) at reconnect (/home/runner/work/hyperliquid/hyperliquid/src/transport/websocket/_reconnectingSocket.ts:791:12)
entry.sent ? "WebSocket connection closed" : "WebSocket connection closed before the request was sent",
{ request: payloadSnapshot(entry.frame, typeof entry.id === "number") },
),
Expand All @@ -234,6 +235,12 @@
for (const entry of this._queue) {
entry.sent = true;
this._socket.send(entry.frame);
// Only `post` entries (numeric id) debit the message budget here: their frame is
// reaching the socket for the first time and the send-immediately charge in
// `request` never ran. Subscription entries already paid a token in `acquireSend`
// at request() time; charging them again would double-count every flushed frame.
// See the send-immediately branch in `request` for the full charging story.
if (typeof entry.id === "number") this._quota?.chargeSend();
}
});

Expand Down Expand Up @@ -277,14 +284,41 @@
//
// `post` is deliberately excluded: `_shell.ts` fixes the wire order of an exchange
// action on `transport.request` reaching `send` synchronously, so awaiting here would
// let a later nonce overtake an earlier one. Posts debit the budget without waiting
// (below), which still slows subscription traffic when orders are heavy but can never
// delay or reorder an order. `acquireSend` returns `undefined` rather than a resolved
// promise when nothing needs waiting on, keeping this function synchronous to `send`
// for every request that is not actually being throttled.
if (method !== "post") {
const paced = this._quota?.acquireSend(signal);
if (paced !== undefined) await paced;
// let a later nonce overtake an earlier one. Posts debit the budget without waiting —
// when their frame reaches the socket, in the send-immediately branch below or in the
// `open` flush — which still slows subscription traffic when orders are heavy but can
// never delay or reorder an order. `acquireSend` returns `undefined` rather than a
// resolved promise when nothing needs waiting on, keeping this function synchronous to
// `send` for every request that is not actually being throttled.
if (method !== "post" && this._quota !== undefined) {
// The wait must not outlive the connection: the socket's termination signal abandons
// the wait when the transport closes for good. Otherwise each paced waiter of a
// closed transport would still consume a token for a frame that can never send — and
// hold its FIFO slot ahead of live transports sharing the quota. A caller signal
// composes with it via `AbortSignal.any`, with the caller's reason winning when both
// are already aborted — the same precedence the controller relay below reproduces.
// No in-repo paced caller passes a signal today (the subscription manager never
// does), so the common path allocates no composite.
const pacingSignal =
signal === undefined
? this._socket.terminationSignal
: AbortSignal.any([signal, this._socket.terminationSignal]);
const paced = this._quota.acquireSend(pacingSignal);
if (paced !== undefined) {
try {
await paced;
} catch (error) {
// Wrapped here because this await runs before the main try — whose catch would
// never see this rejection — and the dispatcher's contract is to reject only
// with WebSocketRequestError. `payload` is safe to attach: only the subscription
// manager reaches this path, and it always hands over its own plain-data snapshot.
const terminated = this._socket.terminationSignal.aborted && error === this._socket.terminationSignal.reason;
throw new WebSocketRequestError(
terminated ? "WebSocket connection permanently terminated" : "Request aborted",
{ cause: error, request: payload },
);
}
}
}

// One controller per request: the timeout timer, the user signal, and the
Expand Down Expand Up @@ -346,9 +380,12 @@
const sent = this._socket.readyState === ReconnectingWebSocket.OPEN;
if (sent) {
this._socket.send(frame);
// A `post` never waited above, so it debits the shared budget here instead —
// driving it into debt when orders outpace the refill, which later `subscribe`
// frames wait off. Subscribes already paid in `acquireSend`.
// The charging story, stated once: every frame debits the message budget exactly
// one time. `subscribe`/`unsubscribe` paid a token in `acquireSend` above, whether
// the frame goes out here or from the `open` flush. A `post` never waited there, so
// it debits when its frame reaches the socket — here when connected, in the `open`
// flush when queued while disconnected. Post debits can drive the bucket into debt
// when orders outpace the refill, which later `subscribe` frames wait off.
if (method === "post") this._quota?.chargeSend();
}

Expand Down
56 changes: 46 additions & 10 deletions src/transport/websocket/_quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
*
* One instance of this class is shared by every transport pointed at the same deployment
* (see {@linkcode sharedWebSocketQuota}), so the counts the guards read are the counts the
* server keeps.
* server keeps. That shared instance also paces outbound messages against the 2000/minute
* budget by default: the default transport is exactly the one that overruns it, since at the
* 1000-subscription cap one reconnect re-sends every subscribe frame instantly and a flapping
* socket repeats the burst. A directly constructed `new WebSocketQuota()` stays
* accounting-only, which is the opt-out (pass it via `WebSocketTransportOptions.quota`).
*
* @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits
* @module
Expand Down Expand Up @@ -94,14 +98,18 @@ export interface WebSocketRateLimitOptions {
/** Configuration options for a {@linkcode WebSocketQuota}. */
export interface WebSocketQuotaOptions {
/**
* Opt-in pacing of outbound messages against Hyperliquid's 2000-per-minute per-IP budget.
* Pacing of outbound messages against Hyperliquid's 2000-per-minute per-IP budget.
*
* When set, `subscribe` and `unsubscribe` frames wait for a token before going out.
* `post` frames and keep-alive pings never wait — they debit the bucket without blocking
* (see {@linkcode WebSocketQuota.chargeSend}) — so enabling this can only ever delay
* subscription traffic, never an order.
*
* Default: `undefined` (accounting only; nothing waits)
* Default: `undefined` (accounting only; nothing waits). That default governs direct
* construction only — the {@linkcode sharedWebSocketQuota} instance every transport uses
* unless given its own is created with pacing enabled (`{}`: capacity 2000, refilling
* 2000/minute). Opting out of pacing therefore means passing an accounting-only
* `new WebSocketQuota()` via `WebSocketTransportOptions.quota`.
*/
rateLimit?: WebSocketRateLimitOptions;
/**
Expand Down Expand Up @@ -230,11 +238,21 @@ export class WebSocketQuota {
/**
* Waits until the outbound budget covers one message, then deducts it.
*
* Returns `undefined` — not a resolved promise — whenever the wait is unnecessary, so the
* caller can skip the `await` entirely and stay synchronous. That distinction is load
* bearing: `_shell.ts` fixes the wire order of an exchange action on `transport.request`
* running synchronously up to its first await, so a promise handed back here where none
* was needed would let a later nonce overtake an earlier one.
* Returns `undefined` — not a resolved promise — whenever the wait is unnecessary: when
* pacing is off, and on the {@linkcode TokenBucketRateLimiter.tryAcquire} fast path when
* the bucket covers the message with no queue in front of it. The caller can then skip
* the `await` entirely and stay synchronous. That distinction is load bearing:
* `_shell.ts` fixes the wire order of an exchange action on `transport.request` running
* synchronously up to its first await, so a promise handed back here where none was
* needed would let a later nonce overtake an earlier one — and with pacing on by default
* (see {@linkcode sharedWebSocketQuota}), a plain `acquire` would hand back exactly such
* a promise for every uncontended send.
*
* "Unnecessary" is judged by the synchronous probe: in the sliver between a failed probe
* and the queued `acquire`, the clock can cross a refill boundary and hand back an
* already-resolved promise instead of `undefined`. That costs the subscribe frame one
* microtask and nothing else — a `post` never enters this path, so no order can be
* delayed or reordered by it.
*
* Only `subscribe` / `unsubscribe` frames go through this path. See
* {@linkcode WebSocketQuota.chargeSend} for the frames that must never wait.
Expand All @@ -243,6 +261,15 @@ export class WebSocketQuota {
*/
acquireSend(signal?: AbortSignal): Promise<void> | undefined {
if (this._limiter === null) return undefined;
// An already-aborted caller must not spend budget: its frame will never be sent, and
// repeated aborted attempts would drain the shared bucket and throttle the legitimate
// subscription traffic behind it. Mirrors `acquire`'s own already-aborted contract,
// which the synchronous probe below would otherwise bypass.
if (signal?.aborted) return Promise.reject(signal.reason);
// The synchronous probe deducts the token inline whenever it can do so without stealing
// a queued waiter's turn; only a genuinely empty bucket — or one with a queue in front
// of it — costs the caller a promise.
if (this._limiter.tryAcquire(1)) return undefined;
return this._limiter.acquire(1, signal);
}

Expand Down Expand Up @@ -279,8 +306,17 @@ export class WebSocketQuota {
*/
const SHARED: { mainnet?: WebSocketQuota; testnet?: WebSocketQuota } = {};

/** The quota every transport on `isTestnet` shares unless it was given its own. */
/**
* The quota every transport on `isTestnet` shares unless it was given its own.
*
* Created with pacing enabled ({@linkcode WebSocketQuotaOptions.rateLimit} `{}`: capacity
* 2000, refilling 2000/minute — the server's own budget), because the default transport is
* exactly the one that trips the limit: at the 1000-subscription cap a single reconnect
* re-sends every subscribe frame instantly, spending half the minute's budget in one burst,
* and a socket flapping under `maxRetries: Infinity` repeats it. To opt out of pacing, pass
* an accounting-only `new WebSocketQuota()` via `WebSocketTransportOptions.quota`.
*/
export function sharedWebSocketQuota(isTestnet: boolean): WebSocketQuota {
const key = isTestnet ? "testnet" : "mainnet";
return (SHARED[key] ??= new WebSocketQuota());
return (SHARED[key] ??= new WebSocketQuota({ rateLimit: {} }));
}
16 changes: 11 additions & 5 deletions src/transport/websocket/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,21 @@ export interface WebSocketTransportOptions {
*
* Hyperliquid scopes every WebSocket limit to the client IP rather than to the connection,
* so by default all transports on the same network share one {@linkcode WebSocketQuota} and
* the guards count what the server counts. Pass an instance to override that — a process
* behind several egress IPs needs one quota per IP, and a test usually wants isolation.
* the guards count what the server counts. The shared default also paces outbound messages
* against the documented 2000/minute budget: `subscribe`/`unsubscribe` frames wait for a
* token when the budget is spent, while `post` frames and keep-alive pings only ever debit
* it, so pacing can delay subscription traffic but never an order.
*
* @example Pace outbound messages against the documented 2000/minute budget
* Pass an instance to override the default — a process behind several egress IPs needs one
* quota per IP, a test usually wants isolation, and a directly constructed
* `new WebSocketQuota()` (no `rateLimit`) is the opt-out from pacing: it keeps the
* subscription and unique-user guards but never delays a frame client-side.
*
* @example Opt out of outbound message pacing (accounting only; nothing waits)
* ```ts
* import { WebSocketQuota, WebSocketTransport } from "@bloxwap/hyperliquid";
*
* const quota = new WebSocketQuota({ rateLimit: { capacity: 2000, refillPerMinute: 2000 } });
* const transport = new WebSocketTransport({ quota });
* const transport = new WebSocketTransport({ quota: new WebSocketQuota() });
* ```
*
* Default: {@linkcode sharedWebSocketQuota} for this network
Expand Down
35 changes: 35 additions & 0 deletions tests/transport/_rateLimiter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,41 @@ describe("TokenBucketRateLimiter", () => {
assert(next.resolved);
});

test("tryAcquire deducts synchronously while the bucket covers the weight", () => {
const bucket = new TokenBucketRateLimiter(2, 60);

assert(bucket.tryAcquire(1));
assert(bucket.tryAcquire(1));
// The bucket is now empty: the probe refuses without queueing anything.
assertEquals(bucket.tryAcquire(1), false);

// A refused probe spent nothing and armed no timer: the refill alone re-covers it.
time.tick(1_000); // 1 token at 60/minute
assert(bucket.tryAcquire(1));
});

test("tryAcquire never bypasses queued waiters, even when the tokens would cover it", async () => {
const bucket = new TokenBucketRateLimiter(5, 300); // 5 tokens per second

bucket.acquire(5); // empties the bucket
const large = track(bucket.acquire(4)); // queued: needs 800 ms
await flush();

time.tick(600); // 3 tokens: enough for a weight-1 probe on its own, not for the head
// FIFO is not bypassed: the head waiter keeps its turn, so the probe refuses even
// though the bucket covers its weight.
assertEquals(bucket.tryAcquire(1), false);

// And the refusal deducted nothing: the head is served on its original schedule.
time.tick(200); // 4 tokens
await flush();
assert(large.resolved);

// With the queue drained, the same probe succeeds again.
time.tick(200); // 1 token
assert(bucket.tryAcquire(1));
});

test("rejects non-positive, non-finite, or underflowing configuration", () => {
// Zero and negatives would stall the queue forever.
assertThrows(() => new TokenBucketRateLimiter(0, 60), RangeError, "capacity=0");
Expand Down
Loading
Loading