Ozakboy.Http
0.2.0
See the version list below for details.
dotnet add package Ozakboy.Http --version 0.2.0
NuGet\Install-Package Ozakboy.Http -Version 0.2.0
<PackageReference Include="Ozakboy.Http" Version="0.2.0" />
<PackageVersion Include="Ozakboy.Http" Version="0.2.0" />
<PackageReference Include="Ozakboy.Http" />
paket add Ozakboy.Http --version 0.2.0
#r "nuget: Ozakboy.Http, 0.2.0"
#:package Ozakboy.Http@0.2.0
#addin nuget:?package=Ozakboy.Http&version=0.2.0
#tool nuget:?package=Ozakboy.Http&version=0.2.0
Ozakboy.Http
A signing, rate-limiting, retry and log-masking pipeline for HttpClient, built entirely on first-party Microsoft packages.
English | 繁體中文
dotnet add package Ozakboy.Http
Requires .NET 10. Depends only on Microsoft packages and Ozakboy.*.
Why this exists
An automated trading engine needs four things from its HTTP layer: every private request signed, the exchange's weight quota respected, transient faults retried without ever resending an order, and a log that never contains an API key. The usual answers are RestSharp, Flurl and Polly.
This package does those four things on top of the official HttpClientFactory and DelegatingHandler pipeline, with no third-party dependency in the graph.
A note on
Microsoft.Extensions.Http.Resilience: it carries a Microsoft name but depends on the third-partyPolly.Core. Judged by the transitive graph rather than by package name, it does not qualify — which is why the retry handler here is written from scratch.
The pipeline
Four handlers, and the order matters:
signing → rate limiting → retry → sanitising logging → the network
Getting it wrong produces no error at all, only behaviour that is hard to read at runtime:
- Signing inside retry re-signs on every attempt with a fresh timestamp. Some services reject that outright.
- Rate limiting inside retry means a retry storm consumes no weight, and blows through the peer's quota.
- Logging anywhere but innermost records what the caller intended to send rather than what actually went out.
services.AddSingleton(TimeProvider.System);
services.AddHttpClient("exchange", client => client.BaseAddress = new Uri("https://api.example.com"))
.AddOzakboyHttpPipeline(options =>
{
options.Signing.ApiKey = configuration["Exchange:ApiKey"]!;
options.Signing.SecretKey = configuration["Exchange:SecretKey"]!;
options.Signing.ApiKeyHeaderName = "X-MBX-APIKEY";
options.RateLimiting.Buckets.Add(new RateLimitBucket("minute", 2400, TimeSpan.FromMinutes(1)));
options.RateLimiting.Buckets.Add(new RateLimitBucket("second", 300, TimeSpan.FromSeconds(1)));
options.Retry.Policy = RetryPolicy.Default;
options.Timeouts.AttemptTimeout = TimeSpan.FromSeconds(10);
options.Timeouts.OverallTimeout = TimeSpan.FromSeconds(30);
});
Each section can also be registered on its own: AddRequestSigning, AddWeightedRateLimiting, AddRetry, AddSanitizedLogging.
Signing: three details that cause intermittent failures
Parameter order changes the signature. An HMAC signs one concatenated string, so a different order is a different signature. Signing parameters therefore never live in a Dictionary — its enumeration order is unspecified, and when it shifts you get intermittent signature errors that vanish on retry. QueryParameters is append-only, and that order is the signing order.
var parameters = QueryParameters.CreateBuilder()
.Add("symbol", "BTCUSDT")
.Add("quantity", 0.001m) // "0.001", never "1E-03"
.Add("timestamp", timestamp)
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, "/fapi/v1/order")
.WithQueryParameters(parameters)
.WithSignature()
.WithWeight(1);
Encode first, then sign. The string signed must match the string sent, byte for byte. Encoding uses Uri.EscapeDataString, never HttpUtility.UrlEncode — the latter encodes a space as + rather than %20, and the two sides then disagree.
Decimals get no exponent and no trailing zeros. Serialisation goes through Precision.ToPlainString. A quantity sent as 1E-05 usually comes back as an opaque parameter error that gives no hint of the real cause.
The algorithm is substitutable through ISignatureAlgorithm; HmacSha256SignatureAlgorithm is the default and emits lowercase hex.
Rate limiting: weighted, multi-bucket, fake-clock testable
Exchanges enforce several windows at once and charge different weights per endpoint. RateLimitBucket describes one window; a request is admitted only when every bucket can cover its weight.
The quota arithmetic is System.Threading.RateLimiting's TokenBucketRateLimiter — no rate-limiting algorithm is implemented here. What is implemented is the clock: the primitive's replenishment is tied to the real clock even with auto-replenishment off, which makes multi-bucket behaviour untestable. WeightedRateLimiter drives replenishment from a TimeProvider instead, so tests advance a FakeTimeProvider rather than sleeping.
A request whose weight exceeds the smallest bucket fails immediately rather than waiting forever, and a wait longer than AcquisitionTimeout fails as ErrorCategory.RateLimited without the request ever leaving the machine.
Retry: non-idempotent requests are never retried
This is the part that matters most. A timeout says no response arrived, not that the peer never received the request — the connection can drop on the way back, long after the order was accepted. Resending means placing it twice, and the position drift usually surfaces only at reconciliation.
- Safe methods (
GET,HEAD,OPTIONS,TRACE) are retried. POST,DELETE,PUT,PATCHare not, by default.request.AsIdempotent()opts a request in;request.AsNonIdempotent()opts one out.
Making it an explicit declaration is deliberate. With a boolean, the default false looks identical to a considered decision not to retry, and a code review cannot tell them apart.
Backoff comes from RetryPolicy.GetDelay. When a response carries Retry-After, the peer's instruction wins instead (capped by MaxRetryAfter) — the server knows how much of its cooldown remains, and coming back early only extends the ban.
Whether a failure is worth retrying is decided entirely by the policy. Setting RetryPolicy.RetryPredicate replaces the built-in transient check outright — not as an extra condition — and the error it is handed already carries what such a decision needs:
var policy = RetryPolicy.Default with
{
// A 429 that says when to come back is worth another go; one that does not usually means the address is banned.
RetryPredicate = error => error.TryGetDecimal(HttpErrorDataKeys.RetryAfterSeconds, out _),
};
When the attempts run out on a failure that was worth retrying, the error handed back is re-labelled ErrorCategory.Exhausted: the code and message are kept, but IsTransient turns false, so the caller's own retry layer does not multiply the same fault by another round.
Timeouts come in two layers: AttemptTimeout bounds one attempt, OverallTimeout bounds the whole exchange including backoff waits.
Logging: masked, and fail-closed
SanitizingLoggingHandler writes through the ILogger abstraction and binds to no concrete logging implementation. Sensitive query values are masked while the path and the harmless parameters survive, because debugging needs to show which endpoint was called:
HTTP 送出 GET https://api.example.com/fapi/v1/order?symbol=BTCUSDT&apiKey=vmPU****Eh8A&signature=****
Masking is Ozakboy.Security's SecretMasker, whose default name list already covers apiKey, signature, token, authorization and friends, matched case-insensitively. Add your own through AdditionalSensitiveParameterNames, and register the key value itself with RegisterKnownSecret to catch it wherever a peer echoes it back.
If masking fails, the value is discarded — never emitted raw. Fail-open here would write the credential straight into the log while everything still looked fine.
Failures come back as Result<T>
HttpPipelineClient wraps the assembled HttpClient and converts the exception path back into Result<T>, so callers handle one shape rather than remembering which exceptions to catch.
var client = new HttpPipelineClient(httpClient, timeouts);
var result = await client.SendForStringAsync(request, cancellationToken);
if (!result.TryGetValue(out var body))
{
// Category already says whether retrying is worthwhile
if (result.Error.IsTransient) { /* back off and come round again */ }
return result.ToFailure<Order>();
}
HttpErrorMapper does the classification: 429 is RateLimited, 408 is Timeout, every 5xx is Unavailable — all transient. Other 4xx codes are not: the request itself is wrong, and resending it unchanged earns the same answer plus another slice of quota. A caller cancellation maps to Cancelled, which is not transient — it deserves neither a retry nor an alert.
Diagnostic values travel in Error.Data under the keys in HttpErrorDataKeys, and they read back typed: error.TryGetInt64(HttpErrorDataKeys.StatusCode, out var status) and error.TryGetDecimal(HttpErrorDataKeys.RetryAfterSeconds, out var seconds). Nothing has to be parsed back out of a string at the far end, and no consumer gets the chance to forget InvariantCulture.
Callers who use a bare HttpClient instead of the facade catch ResultException from Ozakboy.Core.Abstractions. It is the one carrier every Ozakboy package uses to move an Error across a boundary whose signature belongs to the BCL, its Error property is never null, and it derives from InvalidOperationException — so there is a single exception type to catch rather than one per package.
Testing
dotnet test runs 188 tests with no network and no Thread.Sleep. Signing is pinned to golden vectors published in the Binance documentation, with counter-proofs that parameter order and encoding order really do change the result. Rate limiting and retry timing run on FakeTimeProvider.
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.Http (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
- Ozakboy.Core.Abstractions (>= 0.3.0)
- Ozakboy.Security (>= 0.1.1)
- System.Threading.RateLimiting (>= 10.0.12)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Ozakboy.Http:
| 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. |
|
|
Ozakboy.Line
LINE Login、Messaging API 與 Webhook 三件事的 .NET 用戶端,建構在 Ozakboy.Http 管線之上:預期失敗一律以 Result<T> 回傳而非擲出例外,憑證由管線遮罩後才寫進日誌,POST 只在帶了 X-Line-Retry-Key 時才重試。id_token 以 BCL 的 HMAC-SHA256 本地驗證(不引入任何 JWT 函式庫),webhook 簽章以定時比較驗證。另附強型別的快速回覆與十種動作、可存可渲染的訊息範本,以及六種比對模式的關鍵字自動回覆(含歡迎訊息與備援回覆),圖文選單則有「建立→上傳→設預設→指向別名→刪舊」一次做完的替換流程。除 Microsoft 官方套件與 Ozakboy.* 自研套件外,零第三方相依。A .NET client for LINE Login, the Messaging API, and webhooks, built on the Ozakboy.Http pipeline: expected failures come back as Result<T> rather than exceptions, credentials are masked before they reach a log, and a POST is retried only when it carries an X-Line-Retry-Key. ID tokens are verified locally with the BCL's HMAC-SHA256 (no JWT library is pulled in) and webhook signatures are compared in fixed time. It also carries typed quick replies with all ten action kinds, message templates that can be stored and rendered, keyword auto reply across six match modes including the welcome message and the catch-all, and a rich menu replacement that runs create, upload, set-as-default, repoint-the-alias and delete-the-old in one call. No third-party dependency in the graph. |
GitHub repositories
This package is not used by any popular GitHub repositories.