Ozakboy.WebSockets
0.2.1
dotnet add package Ozakboy.WebSockets --version 0.2.1
NuGet\Install-Package Ozakboy.WebSockets -Version 0.2.1
<PackageReference Include="Ozakboy.WebSockets" Version="0.2.1" />
<PackageVersion Include="Ozakboy.WebSockets" Version="0.2.1" />
<PackageReference Include="Ozakboy.WebSockets" />
paket add Ozakboy.WebSockets --version 0.2.1
#r "nuget: Ozakboy.WebSockets, 0.2.1"
#:package Ozakboy.WebSockets@0.2.1
#addin nuget:?package=Ozakboy.WebSockets&version=0.2.1
#tool nuget:?package=Ozakboy.WebSockets&version=0.2.1
Ozakboy.WebSockets
A WebSocket client built to stay up for weeks, on top of the BCL ClientWebSocket and nothing else.
English | 繁體中文
dotnet add package Ozakboy.WebSockets
Requires .NET 10. Depends only on Ozakboy.Core.Abstractions and Microsoft.Extensions.Logging.Abstractions — no third-party libraries anywhere in the graph.
Why this exists
ClientWebSocket gives you a connection. It does not give you a client that survives a Tuesday.
A market data stream runs for days. In that time the network drops, the peer restarts for maintenance, and the consumer occasionally falls behind. None of that is exceptional; it is the normal operating condition. This package handles it:
| Problem | What happens here |
|---|---|
| Connection drops | Reconnects automatically with backoff and jitter from a RetryPolicy |
| Reconnect succeeds but nothing arrives | Subscriptions are replayed on every connection |
| Peer stops sending without disconnecting | An idle timeout declares the connection dead and replaces it |
| Consumer falls behind | A bounded queue with a configurable strategy, and every dropped message is observable |
| Something fails | A Result, not an exception |
It knows nothing about any exchange. No endpoints, no message formats, no subscription protocols, and no deserialisation — payloads come out as the raw text or bytes that arrived.
Three things we measured
These came out of a 200-second run against a real exchange, and each one shaped the design.
1. Your application never sees a ping
ClientWebSocket answers the peer's pings with a pong by itself, at the protocol layer. There is no API to observe those frames, intercept them, or send one yourself. Tutorials telling you to reply to a ping manually are about other libraries; here the code would compile and send nothing.
So the heartbeat in this package is not about answering the peer. It is about detecting whether the peer is still there:
options.IdleTimeout = TimeSpan.FromSeconds(60);
Nothing received in 60 seconds means the connection is dead — abort it, reconnect, replay the subscriptions. Set it comfortably above the longest normal silence in your stream.
2. Cancelling a receive aborts the connection
This one is easy to get wrong because the code looks right. Passing a CancellationToken to ReceiveAsync and cancelling it does not mean "stop receiving". It means "abort the connection": the socket moves to WebSocketState.Aborted, and from there a graceful close is impossible — the close frame fails to send and the peer sees a connection that was yanked away.
The receive loop here always passes CancellationToken.None. Stopping works the other way round: send the close frame, wait for the peer to echo it, let the loop end when it sees WebSocketMessageType.Close. The socket finishes at Closed. Cancellation is reserved for the hard timeout when the peer never answers, and only then is Abort used.
3. Connected is not the same as receiving
We watched a handshake succeed, the socket sit at WebSocketState.Open, and 200 seconds pass without a single frame. No exception. No disconnect. Nothing in the state to look at. Something in the network path was letting the handshake through and swallowing the data stream.
Checking WebSocketState cannot detect that. Only "how long since the last message" can — which is the idle timeout from point 1, and the reason it is on by default.
Using it
var options = new WebSocketClientOptions
{
Uri = new Uri("wss://stream.example.com/ws"),
IdleTimeout = TimeSpan.FromSeconds(60),
MaxReconnectAttempts = null, // unlimited; the default
QueueCapacity = 4096,
BackpressureStrategy = BackpressureStrategy.DropOldest,
};
await using var client = new WebSocketClient(options, logger);
client.MessageDropped += (_, e) =>
logger.LogWarning("Dropped a message; {Total} lost so far", e.TotalDropped);
await client.SubscribeAsync(new WebSocketSubscription(
id: "btcusdt-trades",
subscribePayload: """{"method":"SUBSCRIBE","params":["btcusdt@trade"],"id":1}""",
unsubscribePayload: """{"method":"UNSUBSCRIBE","params":["btcusdt@trade"],"id":2}"""));
var connect = await client.ConnectAsync();
if (connect.IsFailure)
{
logger.LogError("Could not connect: {Error}", connect.Error);
return;
}
await foreach (var item in client.Messages(cancellationToken))
{
if (item.TryGetValue(out var message))
{
Handle(message.Text!);
continue;
}
// A failure means the connection dropped here and data may be missing.
// IsTransient says which kind it is: true is a gap and the stream carries on,
// false is terminal — the client has stopped and this is the last element.
if (item.Error.IsTransient)
{
logger.LogWarning("Gap in the stream: {Error}", item.Error);
continue;
}
item.Error.TryGetInt64(WebSocketErrorDataKeys.Attempts, out var attempts);
logger.LogError("The client has stopped after {Attempts} attempts: {Error}", attempts, item.Error);
}
Nothing here asks you to branch on an error code. IsTransient is the single source of truth for "is this worth
retrying", and every error this package produces is categorised so that it answers correctly.
Subscriptions registered before connecting go out with the first connection. Subscriptions registered while disconnected are kept and go out with the next one. Every connection replays the full list.
Starting when the peer might not be up yet
ConnectAsync makes one attempt and tells you whether it worked, which is usually what you want at start-up. For a process that runs around the clock and should simply wait for the peer to appear:
client.Start(); // returns immediately; retries with backoff until connected
Backpressure
The queue is bounded, always. An unbounded queue in a process that runs for weeks is a memory leak with extra steps.
| Strategy | Drops | Use it for |
|---|---|---|
DropOldest (default) |
The oldest queued message | Market data, where the newest value is the useful one |
DropNewest |
The message that just arrived | Ordered feeds where older entries cannot be skipped |
Wait |
Nothing — stalls the receive loop | When nothing may be lost, and the consumer is only briefly slow |
Wait has a cost worth understanding: once the receive loop stalls, the peer's send buffer and the TCP window fill up and the peer usually drops the connection, and the idle detector will eventually conclude the connection is dead because nothing new is arriving. It trades losing messages for losing the connection.
Whichever you pick, drops are never silent. They surface through the MessageDropped event and the MessagesDropped counter. A trading system that quietly misses a candle will trade on wrong data, and nobody will know why.
Watching it
var stats = client.Statistics;
The two numbers that matter most:
MessagesDropped— anything above zero means data has already gone unprocessed.ReconnectCountvsSubscriptionReplayCount— these should grow together. Reconnects climbing while replays stay put is what "connected but never resubscribed" looks like.
State and CloseReason tell a normal shutdown apart from one that needs an alert. Everything ends at Closed, so the reason is what separates CallerRequested from ReconnectAttemptsExhausted (the attempts ran out) and UnrecoverableError (a failure retrying could not fix — look on your own side, not at the peer).
Error codes
Branch on WebSocketErrorCodes, not on message text. Messages are for people and get rewritten; codes are part of the contract.
| Code | Category | Meaning |
|---|---|---|
ws.options_invalid |
Validation | The configuration is unusable |
ws.connect_failed |
Network | Handshake failed or refused |
ws.connect_timeout |
Timeout | Handshake did not finish in time |
ws.connection_lost |
Network | The connection dropped; a gap in the data |
ws.idle_timeout |
Timeout | Nothing arrived within the idle window |
ws.reconnect_exhausted |
Exhausted (not transient) | Gave up after the attempts ran out; the client has stopped — the last element in the stream |
ws.unrecoverable |
Exhausted (not transient) | Gave up on a failure retrying cannot fix, without retrying once — the last element in the stream |
ws.not_connected |
Unavailable, or Exhausted once the client is closed | Nothing to send on right now |
ws.send_failed |
Network | The send failed |
ws.subscription_replay_failed |
Network | A replay failed; the connection was abandoned and retried |
ws.subscription_not_found |
NotFound | No subscription with that id |
ws.message_too_large |
Network | One message exceeded MaxMessageSize |
ws.invalid_state |
Conflict | The lifecycle state does not allow this |
ws.cancelled |
Cancelled | The caller cancelled |
The categories are chosen so that IsTransient tells the truth, which means you never have to read this table to
decide whether to retry. ws.reconnect_exhausted is the case worth spelling out: the peer genuinely is unavailable,
but this client instance is finished and retrying it can never work — retrying means constructing a new client — so it
is Exhausted, not the transient Unavailable. ws.not_connected follows the same rule: transient while the client
is connecting or reconnecting, Exhausted once it has closed for good.
The loop applies that same rule to itself. A failure whose IsTransient is false is never retried — retrying could
not change the outcome — so the client ends with ws.unrecoverable and CloseReason.UnrecoverableError instead of
backing off forever. Transient failures still reconnect without limit.
The numbers in an error message are also in Error.Data, so you never have to parse the text. The keys are public
constants on WebSocketErrorDataKeys — read them from there rather than writing the literal, because a typo in a
key has no symptom at all: it compiles, it does not throw, and TryGetXxx just returns false.
| Code | Data keys (WebSocketErrorDataKeys) |
|---|---|
ws.reconnect_exhausted |
Attempts |
ws.unrecoverable |
InnerCode, InnerCategory, Attempts |
ws.connect_timeout, ws.idle_timeout |
TimeoutMs |
ws.message_too_large |
LimitBytes |
ws.subscription_not_found |
SubscriptionId |
ws.subscription_replay_failed |
SubscriptionId, InnerCode |
ws.not_connected |
State |
ws.invalid_state |
State, Operation |
ws.cancelled |
Operation |
Attempts is a count, TimeoutMs is milliseconds and LimitBytes is bytes — all three read with TryGetInt64.
The rest are strings read with TryGetData; State holds a WebSocketClientState name, InnerCode a
WebSocketErrorCodes value, and InnerCategory an ErrorCategory name.
error.TryGetInt64(WebSocketErrorDataKeys.Attempts, out var attempts);
error.TryGetData(WebSocketErrorDataKeys.InnerCode, out var innerCode);
Testing against it
IWebSocketConnection and IWebSocketConnectionFactory are public so you can substitute the transport. Combined with a TimeProvider, that lets you drive reconnects, idle timeouts, and backoff on a fake clock with no network and no waiting:
var client = new WebSocketClient(options, myFakeFactory, logger, myFakeClock);
That is exactly how this package's own tests work — 105 of them, none of which open a socket.
Licence
MIT. See LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Ozakboy.Core.Abstractions (>= 0.3.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Ozakboy.WebSockets:
| Package | Downloads |
|---|---|
|
Ozakboy.TradeKit.Binance
Ozakboy.TradeKit.Abstractions 的幣安 USDⓈ-M 永續合約實作,建構在 Ozakboy.Http 的簽章/限流/重試管線之上,不使用任何第三方社群套件。本版提供交易規則(exchangeInfo 的 PRICE_FILTER / LOT_SIZE / MIN_NOTIONAL / MARKET_LOT_SIZE)對映與每日快照快取、伺服器時間、帳戶與持倉查詢,以及下單、撤單、查單與槓桿/保證金模式設定(下單一律不可重試,並以 clientOrderId 作為冪等識別碼),再加上幣安錯誤碼到交易所中立錯誤碼的完整對映(時間戳偏移、簽章無效、IP 白名單、保證金不足、限流各自可辨識)。REST 與 WebSocket 端點以「環境」成組提供,杜絕主網與 Testnet 混接;交易規則快取以環境為鍵,避免 Testnet 的步進值被誤用到主網。行情方面提供歷史 K 線與盤口(買一賣一)查詢,以及 WebSocket 的 K 線、標記價與盤口訂閱(連線管理、重連與重連後重放訂閱取自 Ozakboy.WebSockets);K 線的「是否已收盤」逐筆正確對映,REST 回應沒有這個旗標的部分則以收盤時間推得,策略不會拿到未收盤的 K 線當成收盤資料。條件單(停損、停利、移動停損)走幣安 2025-12-09 起啟用的 Algo Order 端點,與一般委託分成兩條路徑:送單同樣絕不重試,clientAlgoId 失敗時也帶得回來,狀態變化由使用者資料串流的 ALGO_UPDATE 事件送出,而一般的掛單查詢、委託串流與撤銷全部掛單都看不到條件單,緊急出場必須兩邊都撤。使用者資料串流提供委託、條件單、成交、帳戶增量、保證金追繳與對帳訊號六種訂閱:單一連線內部分流,listenKey 自動建立/續期/重建/刪除,以 LIST_SUBSCRIPTIONS 心跳做閒置偵測;跟不上的訂閱者以失敗結束而不靜默丟棄事件,串流憑證不進任何錯誤與日誌,並在取得當下登記成遮罩器的已知祕密,連本套件管不到的路徑流出的那一份也會被換成遮罩字串。憑證的續期成功、續期失敗與失效都有日誌與計數(日誌一律不含憑證),次數與時刻另以 ListenKeyStatus 快照公開給健康度呈現,續期失敗會以短退避重試而不是空等下一個排程。A Binance USDⓈ-M perpetual futures implementation of Ozakboy.TradeKit.Abstractions, built on the Ozakboy.Http signing, rate-limiting, and retry pipeline with no third-party dependencies. This release covers exchange-info trading rules with a per-environment daily cache, server time, account and position queries, order placement, cancellation and lookup, leverage and margin mode, and a full Binance-to-neutral error code mapping. Order placement is never retried and carries a clientOrderId as its idempotency key. Market data covers historical klines and book ticker snapshots plus WebSocket kline, mark price, and book ticker subscriptions, with the candle closed flag mapped from the stream and derived from the close time on REST, never assumed. Conditional orders (stop, take-profit, trailing) use the Algo Order endpoints Binance switched to on 2025-12-09 and form a path of their own: placement is never retried, the clientAlgoId comes back even on failure, state changes arrive on the user data stream as ALGO_UPDATE, and the ordinary open-orders query, order stream, and cancel-all never see them, so an emergency exit has to clear both sides. The user data stream covers order updates, conditional order updates, fills, account deltas, margin calls, and resync signals over one fanned-out connection, with a fully managed listenKey and heartbeat-based idle detection; a subscriber that falls behind ends with a failure instead of silently losing events, and the stream credential never reaches an error or a log and is registered as a known secret on the client masker the moment it is obtained, so even a copy leaving by a route this package does not control comes out masked. Credential renewals, their failures, and an expiry are logged and counted without the credential ever appearing in a line, the counts and instants are exposed as a ListenKeyStatus snapshot for health display, and a failed renewal is retried after a short backoff rather than waiting out the next scheduled attempt. |
GitHub repositories
This package is not used by any popular GitHub repositories.
0.2.1 — Security fix: the connection log no longer writes the URI's path or query, only the scheme and host. A path can carry a credential (a Binance user data stream is wss://host/ws/<listenKey>) and this line is written on every reconnect, so the log was accumulating usable credentials. Full notes: https://github.com/ozakboy/Ozakboy.WebSockets/blob/main/CHANGELOG.md