Ozakboy.Http 0.3.3

dotnet add package Ozakboy.Http --version 0.3.3
                    
NuGet\Install-Package Ozakboy.Http -Version 0.3.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Ozakboy.Http" Version="0.3.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Ozakboy.Http" Version="0.3.3" />
                    
Directory.Packages.props
<PackageReference Include="Ozakboy.Http" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Ozakboy.Http --version 0.3.3
                    
#r "nuget: Ozakboy.Http, 0.3.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Ozakboy.Http@0.3.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Ozakboy.Http&version=0.3.3
                    
Install as a Cake Addin
#tool nuget:?package=Ozakboy.Http&version=0.3.3
                    
Install as a Cake Tool

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-party Polly.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:

retry → rate limiting → signing → sanitising logging → the network

Two rules sit behind it: every attempt is a new request, and a timestamp is the moment the request goes out. Retry sits outermost, so the other three run again on every attempt:

  • Rate limiting inside retry. Every attempt pays its own weight. Outside retry, a request retried N times would pay once, and the local quota would under-count exactly when the error rate is high — which is when a weight-based ban (418) arrives. The backoff wait happens outside the limiter and holds no permit.
  • Signing inside rate limiting. A request is signed, and with Signing.TimestampParameterName set, stamped with the current time, only once its permit is held. Sign first and queue afterwards, and the timestamp ages in the queue: the limiter waits up to 30 seconds by default, while Binance's recvWindow is 5. Every retry is re-signed the same way. The signing handler is also what writes WithQueryParameters into the URI; with EnableSigning = false an internal handler takes the same position and writes the parameters with the same order, encoding and replace-the-existing-query rule, only unsigned (before 0.3.3 an unsigned pipeline dropped them).
  • Logging innermost. It records what actually went out: admitted, signed, and which attempt it was.

This order took two fixes. 0.2.0 attached signing → rate limiting → retry → logging while its comments described the opposite, so retries reused a stale timestamp and paid no weight. A 0.3.0 draft then tried retry → signing → rate limiting, which signed requests before they queued, so one that queued past recvWindow was rejected on arrival (-1021). A wrong order raises no error, only hard-to-read runtime behaviour, so every point is pinned by a test. See the changelog.

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.Signing.TimestampParameterName = "timestamp";   // restamped on every attempt

        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);
    });

// Recommended: the facade picks up this client's masker and the same timeouts from the registration.
services.AddSingleton(provider => provider.CreateOzakboyHttpPipelineClient("exchange"));

CreateOzakboyHttpPipelineClient is the recommended way to get an HttpPipelineClient. The facade is the last checkpoint an error passes before leaving the package, and it masks with whatever masker it was built with; constructed by hand, forgetting the masker raises no error, it just leaves that checkpoint knowing only SecretMasker.Default. The overall timeout comes from the same registration, so it cannot drift from the one the retry handler uses. It is a method on the service provider rather than another registration so the lifetime stays yours.

Why the facade can be a singleton while an HttpClient must not be held. The AddSingleton above is deliberate: the facade carries no mutable state — timeouts, time source and masker are read-only settings. What it does not do is hold an HttpClient. It holds the IHttpClientFactory and the client name, and takes a client per request. That distinction is the whole point: IHttpClientFactory rotates its handlers on a lifetime (SetHandlerLifetime, two minutes by default), and rotation only gets its chance on each CreateClient call. A client captured once and kept stays bound to a single handler and the connections it has already opened, so the process never learns that the host has moved to a new address — and exchanges do move. Nothing in the logs explains it; on a long unattended run the requests simply stop getting through. CreateClient is cheap and the expensive part — the handler — is pooled by the factory, so calling it per request is the official guidance rather than a workaround.

What AddOzakboyHttpPipeline does to the client

Besides attaching the four handlers, it changes three things on the named client:

  • It registers secrets. Signing.ApiKey, Signing.SecretKey, and every value in options.KnownSecrets go onto a masker that belongs to this client — a singleton keyed by client name, so it survives IHttpClientFactory rebuilding its handlers. A secret that only turns up at run time goes onto the same masker with provider.GetOzakboyHttpMasker("exchange").RegisterKnownSecret(value).
  • It removes IHttpClientFactory's default logging (RemoveAllLoggers()). That logging writes the full URI at Information level, and .NET redacts the query, not the path — a credential in the path, like Telegram's /bot<token>/, goes straight into the log. The pipeline's own logging handler is masked and replaces it.
  • It sets HttpClient.Timeout to infinite. Timeouts belong to the pipeline: the retry handler bounds each attempt, HttpPipelineClient the whole exchange. The default 100 seconds, if shorter than OverallTimeout, would fire first as a cancellation, and a timeout would be misfiled as the caller cancelling. Any Timeout configured earlier is overridden.

Each section can also be registered on its own — AddRetry, AddWeightedRateLimiting, AddRequestSigning, AddSanitizedLogging — in that order. Getting the order right is then your job. AddRequestSigning is also what writes WithQueryParameters into the URI, so a segmented pipeline without it sends no query parameters.


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, PATCH are 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. The attempt clock starts only once the rate-limit permit is held: it measures how long the peer takes to answer, not how long the request queued. Otherwise, with Binance's default 10-second attempt bound shorter than the limiter's 30-second ceiling, any request that queued past 10 seconds would time out and be retried at the back of the queue. The overall bound still covers the queue.

A local rate-limit timeout (http.rate_limit.timeout) is not retried by the default policy: the request never went out and has already waited out the limiter's full ceiling, so another attempt only multiplies the wait. A policy with a RetryPredicate decides for itself.


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.

Exceptions never reach a logger, or an Error, as themselves. A transport exception's message often carries the request URI, and an exception object cannot be masked: Message is read-only and the inner chain cannot be rewritten. So the logger receives a SanitizedException instead — the original type name, the masked message, and the masked ToString() with its stack — and every Error.Exception this package produces is one too, with Error.Message and Error.Data passed through the same replacement. Name rules cannot reach a path segment or an exception message; only registered values can, which is why the pipeline registers the signing keys for you and KnownSecrets exists.


Failures come back as Result<T>

HttpPipelineClient sits in front of the assembled pipeline and converts the exception path back into Result<T>, so callers handle one shape rather than remembering which exceptions to catch.

// From a service container: the facade takes a client from the factory per request.
var client = provider.CreateOzakboyHttpPipelineClient("exchange");

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.

Its InnerException, like Error.Exception, is a SanitizedException, never the original type: branch on Error.Code and Error.Category rather than on exception types. And build HttpPipelineClient with CreateOzakboyHttpPipelineClient, which hands it the client's masker; it is the last checkpoint an error passes on its way out, and without that masker it only knows the secrets on SecretMasker.Default.

There is a second construction path for pipelines assembled by hand, outside a service container: new HttpPipelineClient(httpClient, timeouts, timeProvider, masker). The facade then uses the client it was handed and never obtains another, so that client's lifetime — and with it whether handlers ever rotate and DNS stays current — is the caller's responsibility. Within a service container, prefer CreateOzakboyHttpPipelineClient, which takes the factory path described above.


Testing

dotnet test runs 215 tests with no network and no Thread.Sleep. The pipeline order is pinned by tests that go red under the 0.2.0 order and under the signing-before-rate-limiting draft, and a canary secret is walked through the failure paths to check every log form and every field of the returned error. 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.

Version Downloads Last Updated
0.3.3 133 9/14/2026
0.3.2 114 9/13/2026
0.3.1 99 9/13/2026
0.3.0 101 9/11/2026
0.2.0 111 9/11/2026

0.3.3: an unsigned pipeline now sends its query parameters. Parameters set with WithQueryParameters were only ever written into RequestUri by SigningHandler, and AddOzakboyHttpPipeline does not attach SigningHandler when EnableSigning = false, so every query parameter on such a client silently vanished: the request still went out and a response came back, but the peer reported a missing mandatory parameter (Binance: -1102 on GET /fapi/v1/klines without symbol). Affected: AddOzakboyHttpPipeline with EnableSigning = false and requests using WithQueryParameters. With signing off, an internal handler now takes the signing handler's position (inside rate limiting, outside logging) and writes the parameters using the same QueryParameters.ToQueryString() and the same URI write rule as the signing path, so order, encoding and the replacement of an existing query are identical whether or not a pipeline signs; with signing on, SigningHandler still writes the query once. Segmented registration is unchanged: AddRequestSigning still writes the query, and a segmented pipeline without it still sends none. No public signature changed. 0.3.2: fixes a 0.3.1 regression on the shutdown path. A service container disposes what it created in reverse order of creation, which is what makes a dependant go before the things it depends on. By moving CreateClient from facade construction to each request, 0.3.1 also moved when the named client's handler chain — and the container-held WeightedRateLimiter keyed by client name — is first created, so the limiter was disposed before the service sending requests through it. Anything sending a farewell request from its own DisposeAsync then failed with ObjectDisposedException; the Binance user data stream's DELETE of its listenKey is one, and it silently stopped going out. The factory constructor now builds the handler chain at construction (one client taken and dropped) while still taking a client per request, so 0.3.1's handler rotation is unchanged. No public signature changed. 0.3.1: the facade no longer holds an HttpClient for its whole life. provider.CreateOzakboyHttpPipelineClient(name) now hands HttpPipelineClient the IHttpClientFactory and the client name, and a client is taken per request, so IHttpClientFactory's handler rotation (SetHandlerLifetime) actually happens and DNS keeps up when the peer moves to a new IP — the facade is normally registered as a singleton, and a captured client would have pinned it to one handler and its open connections for the life of the process. New constructor HttpPipelineClient(IHttpClientFactory, clientName, timeouts, timeProvider, masker); the HttpClient constructors are unchanged and still supported for hand-assembled pipelines, where the caller owns the client's lifetime. No signature changed and no behaviour other than client acquisition. 0.3.0: pipeline order fixed to retry, rate limiting, signing, logging (0.2.0 had it the other way round, contradicting its own comments), so every attempt pays its own rate-limit weight and is signed with a fresh timestamp only after its permit is held. Time spent queuing for permits no longer counts toward the per-attempt timeout, and a local rate-limit timeout is not retried by the default policy. New provider.CreateOzakboyHttpPipelineClient(name) builds the facade with that client's masker and timeouts. Exceptions are never handed to the logger or placed in Error unmasked (SanitizedException). AddOzakboyHttpPipeline registers the signing keys and HttpPipelineOptions.KnownSecrets on a per-client masker, removes IHttpClientFactory's default logging, and sets HttpClient.Timeout to infinite. See https://github.com/ozakboy/Ozakboy.Http/blob/main/CHANGELOG.md