Wiring the Keyright SDK into ASP.NET Core DI as a typed HttpClient with resilience
Most Keyright integrations start the same way: someone needs to validate a license against the self-hosted issuing service, so they write new HttpClient(), point it at the ServiceUrl, and ship it. It works in development and on the demo. Then it goes to production and one of two things happens. Either the client is created per call — a fresh HttpClient inside the method that checks the license — and under real traffic the process runs out of sockets. Each disposed client leaves connections in TIME_WAIT, the ephemeral port range fills, and you start seeing SocketException: Only one usage of each socket address is normally permitted. Or someone reads the "reuse one HttpClient" advice, makes a single static instance, and holds it for the life of the process — which fixes sockets but freezes DNS. The day you move or scale your issuing service, that static client keeps dialing the address it resolved at startup, long after it is gone. Both failures come from managing the HttpMessageHandler by hand. The right answer in ASP.NET Core is to stop doing that and register the Keyright client as a typed client through IHttpClientFactory, which pools and rotates the handler for you, and then to layer a resilience pipeline so a brief issuing-service blip degrades gracefully instead of hard-failing a paying customer. Key takeaway: Register the Keyright client with AddHttpClient() so the factory pools and rotates the underlying handler — no socket exhaustion, no stale DNS. Inject IKeyrightClient as you would any service (the wrapper is singleton-safe; the HttpClient is not held forever). Attach AddStandardResilienceHandler() with a timeout, retry-with-jitter, and a circuit breaker tuned for a licensing call — retry the idempotent ValidateAsync/RefreshAsync, be careful with activation — and fall back to cached validation when the service is briefly unreachable. The two anti-patterns, concretely Here is the socket-exhaustion version. It looks harmless because every client is disposed. // ANTI-PATTERN 1: a new HttpClient per license check. public async Task IsLicensedAsync(string key, CancellationToken ct) { using var http = new HttpClient { BaseAddress = new Uri("https://keyright.example.com") }; var client = new KeyrightClient(http, _options); // new handler every call var info = await client.ValidateAsync(key, ct); return info.IsValid; } using disposes the client, but the TCP connection it opened lingers in TIME_WAIT for a couple of minutes. A few hundred checks a second and you are out of ports. The second anti-pattern inverts the problem: // ANTI-PATTERN 2: one static HttpClient for the life of the process. static readonly HttpClient Http = new() { BaseAddress = new Uri("https://keyright.example.com") }; static readonly KeyrightClient Client = new(Http, Options); // Reuses connections — but the handler never rotates, so DNS is cached forever. This one survives load, then fails silently on the next infrastructure change. Neither is wrong about HttpClient being expensive to create or cheap to reuse — they are each half right, and the half they are missing is handler rotation. Registering Keyright as a typed client The fix is one registration at startup. AddHttpClient() tells the factory to construct KeyrightClient with an HttpClient whose handler comes from the pool. You configure the client — base address, timeout, default headers — in the callback, and the SDK-level options (product, public key) through the usual KeyrightOptions. var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(o => { o.Product = "acme-suite"; o.PublicKeyBase64 = builder.Configuration["Keyright:PublicKey"]!; // ships in your binary; not a secret o.ServiceUrl = builder.Configuration["Keyright:ServiceUrl"]!; // your self-hosted instance }); builder.Services .AddHttpClient((sp, http) => { var opts = sp.GetRequiredService().Value; http.BaseAddress = new Uri(opts.ServiceUrl); http.Timeout = Timeout.InfiniteTimeSpan; // let the resilience pipeline own timeouts http.DefaultRequestHeaders.Add("X-Keyright-Product", opts.Product); }) .AddStandardResilienceHandler(); // Microsoft.Extensions.Http.Resilience Two details matter here. First, KeyrightClient takes an HttpClient in its constructor — the typed-client overload resolves the rest of its dependencies from DI, so the public key and product flow in through KeyrightOptions. Second, the client's own HttpClient.Timeout is set to infinite on purpose: when a resilience pipeline is attached, you want it to own the per-attempt timeout, not the blunt whole-request timeout baked into HttpClient, which cannot distinguish a slow first attempt from a retry. If you prefer a single call site, wrap the registration in an extension method so applications just say AddKeyright(...): public static IServiceCollection AddKeyright( this IServiceCollection services, Action configure) { services.Configure(configure); services.AddHttpClient((sp, http) => { var o = sp.GetRequiredService().Value; http.BaseAddress = new Uri(o.ServiceUrl); http.Timeout = Timeout.InfiniteTimeSpan; }) .AddStandardResilienceHandler(); return services; } Now the whole thing is builder.Services.AddKeyright(o => { o.Product = "acme-suite"; /* … */ });, and consumers inject IKeyrightClient without ever seeing an HttpClient. Lifetimes: why the wrapper is singleton-safe but the handler is not AddHttpClient() registers the typed client as transient by default, and that is deliberate. The client object itself is cheap — it is a thin wrapper — and each instance it hands you is bound to an HttpClient the factory produced. The expensive, long-lived thing is the HttpMessageHandler underneath, and the factory keeps a pool of those, rotating each out after its handler lifetime (two minutes by default) so connections are reused but DNS does not go stale. The practical rule: resolve IKeyrightClient wherever you need it and use it; do not capture the HttpClient it wraps in a field of your own singleton. The Keyright wrapper's state — the embedded public key, the cached lease, the last-known license edition — is process-wide and safe to treat as a singleton, which is why KeyrightClient.Initialize(...) returns a long-lived object in non-DI apps. In ASP.NET Core you get the same ergonomics through the factory without freezing a handler, because the cached lease lives in the SDK, not in the HttpClient. public sealed class LicensedFeature(IKeyrightClient license) { public async Task RunAsync(CancellationToken ct) { // Pure, offline check against the embedded public key — no network, never throws. var info = license.Validate(); if (!info.IsEnabled("acme.export")) return Results.StatusCode(StatusCodes.Status402PaymentRequired); return Results.Ok(await DoExportAsync(ct)); } } Validate() serves the gate decision from the cached lease, so it is fine on the hot path. The network call lives only in RefreshAsync, which you run on a timer — not on every request. The resilience pipeline, tuned for a licensing call AddStandardResilienceHandler() gives you a sensible default stack: a total request timeout, a retry strategy, a circuit breaker, and a per-attempt timeout, in that order. The defaults are tuned for a generic downstream API; for a licensing call you want to adjust a few knobs. builder.Services .AddHttpClient(ConfigureClient) .AddStandardResilienceHandler(o => { // Per-attempt timeout: the issuing service answers a validate in well under a second. o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3); // Retry: idempotent validate/refresh is safe to repeat. Exponential + jitter so a // fleet recovering from a blip does not stampede the service in lockstep. o.Retry.MaxRetryAttempts = 3; o.Retry.BackoffType = DelayBackoffType.Exponential; o.Retry.UseJitter = true; o.Retry.Delay = TimeSpan.FromMilliseconds(200); // Circuit breaker: once the service is clearly down, stop hammering it and fail fast, // letting the SDK's cached-lease path take over instead of queueing doomed requests. o.CircuitBreaker.FailureRatio = 0.5; o.CircuitBreaker.MinimumThroughput = 10; o.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30); o.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15); // Total timeout must exceed AttemptTimeout × attempts, or retries get cut off. o.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(15); }); The standard handler retries only on transient conditions — connection failures, HttpRequestException, timeouts, 5xx, and 408 — and never on a 4xx. That default is exactly right for licensing: a 403 or a 409 from the issuing service is a definitive answer (revoked, seat withdrawn, product mismatch) and must not be retried, while a 503 during a rolling deploy is transient and should be. If you would rather build the pipeline by hand — for example to vary the strategy per operation — Polly's ResiliencePipelineBuilder drops in the same way: var pipeline = new ResiliencePipelineBuilder() .AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Exponential, UseJitter = true, ShouldHandle = static args => ValueTask.FromResult( args.Outcome.Result is { StatusCode: >= HttpStatusCode.InternalServerError } || args.Outcome.Exception is HttpRequestException or TimeoutException), }) .AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions()) .AddTimeout(TimeSpan.FromSeconds(3)) .Build(); Validate is retry-safe; activation is not This is the one place where a single policy for every call gets you in trouble. ValidateAsync and RefreshAsync are idempotent reads — they ask for the current state and change nothing — so retrying a timed-out one is always safe. Activation is different. It can consume a seat or bind a machine, so a blind retry risks double-counting: if the first attempt actually reached the service and succeeded but the response was lost on the way back, a retry activates a second time. Register activation on its own named client with a more conservative policy, and lean on an idempotency key the service de-duplicates on: // A separate, named client for the non-idempotent activation call. builder.Services.AddHttpClient("keyright-activate", ConfigureClient) .AddResilienceHandler("activate", b => b .AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 1, UseJitter = true }) .AddTimeout(TimeSpan.FromSeconds(10))); // …and pass an idempotency key so a retried activation is coalesced server-side. var result = await license.ActivateAsync( key, new ActivationRequest { IdempotencyKey = deviceInstallId }, ct); With the idempotency key, even the one permitted retry cannot double-consume a seat: the service recognises the repeated key and returns the original result rather than activating again. Degrading gracefully instead of hard-failing The reason all of this matters is that the network call to the issuing service should never be the thing that takes your customer down. A validate that times out once, retries, and succeeds is invisible. A service that is genuinely down for a minute trips the circuit breaker, and instead of every request queueing behind a doomed connection, the breaker fails fast and your gate decisions fall back to the SDK's cached lease — the same cached-validation path that keeps a brief outage from locking anyone out. When the service returns, the breaker half-opens, a probe succeeds, and traffic resumes with no restart and no intervention. Testing the wiring You can test the registration and the resilience behaviour without a live issuing service by swapping the primary handler for a stub. ConfigurePrimaryHttpMessageHandler replaces the innermost handler while leaving the resilience handlers in place, so you are exercising the real pipeline. var services = new ServiceCollection(); services.AddKeyright(o => { o.Product = "acme-suite"; o.ServiceUrl = "https://keyright.test"; }); // Fail twice, then succeed — proves the retry strategy is wired and bounded. var attempts = 0; services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new StubHandler(_ => ++attempts < 3 ? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) : new HttpResponseMessage(HttpStatusCode.OK) { Content = ValidLeaseJson() }))); var client = services.BuildServiceProvider().GetRequiredService(); var info = await client.RefreshAsync(CancellationToken.None); Assert.Equal(3, attempts); // two transient failures were retried Assert.Equal(RefreshStatus.Confirmed, info.Status); A second test points the stub at a 403 and asserts the call is not retried and surfaces as a definitive rejection — that is your guard against someone loosening the ShouldHandle predicate until revocations start getting retried. That is the whole shape of a correct Keyright integration in ASP.NET Core: one typed-client registration so handlers are pooled and rotated, a resilience pipeline that retries the idempotent calls and respects the definitive ones, and a cached lease underneath so a blip in your issuing service costs a retry — never a customer.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to