Install
$ agentstack add skill-snowbanksdk-foundationdb-dotnet-client-snowbank-betterhttp ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
BetterHttpClient - outbound HTTP in an application (DI, options, requests, test interop)
SnowBank.Networking.Http replaces ad-hoc HttpClient / legacy HttpWebRequest usage with a small set of load-bearing pieces. This skill is the consumer guide: wiring the DI, declaring named clients, making requests, and what the distributed test framework adds automatically. For diagnosing multi-node tests themselves (journal, packet capture output, log knobs) see the snowbank-distributed-testing skill.
> 7.4.4 changed the model. A client name now maps to one effective policy that lives in the pooled handler chain, so every client behaves the same, including a plain injected HttpClient. The old IBetterHttpClientFactory, the BetterHttpClient wrapper, BetterHttpShellOptions, IBetterHttpFilter, and the global-filter helpers still work but are [Obsolete] warnings now. Section 10 maps each retired member to its replacement.
1. The mental model: one policy per name, on the chain
Three layers, each with its own lifetime:
| Piece | What it is | Lifetime | Who owns it | |---|---|---|---| | Named policy | A name registered with AddBetterHttpClient(name, configure): TLS policy, credentials, default headers, hooks, delegating handlers. A name is client policy, not an origin: the call site provides the absolute target URI at request time. | registration | you (startup code) | | Pooled handler chain | The actual HttpMessageHandler stack built per name: transport at the bottom, the pipeline handler and any application handlers on top. Owns the sockets. | pooled, managed by Microsoft.Extensions.Http | the platform | | HttpClient | A plain client the factory hands you: a typed client, a keyed client, or one from IHttpClientFactory.CreateClient(name). Cheap to create, and disposing it never tears down the shared sockets. | transient, per use | you |
The chain, bottom to top:
[HttpClient, however obtained] ...)` extension) is enriched and runs the same lifecycle.
This design gives two properties:
- **Late binding**: the target host is resolved per request against the live `INetworkMap`, not captured when the handler is built. A long-lived client keeps working across topology changes (a restarted backend, a re-pointed VIP).
- **No default chain rotation**: names are registered with an infinite handler lifetime, because the transport already bounds DNS staleness itself (`PooledConnectionLifetime` on the shared `SocketsHttpHandler`). To rebuild the chain periodically for a name, opt back in after registration: `services.AddHttpClient(name).SetHandlerLifetime(...)`.
**Why the URI belongs at the call site, not in the registration.** `HttpClient.BaseAddress` is immutable after the first request, so an address baked in at registration time *cannot* follow a configuration change an admin makes at run time. The pooled transport, by contrast, is origin-agnostic (`SocketsHttpHandler` pools per-origin internally), so a call site that passes an absolute URI re-targets: the same client starts hitting the new origin's pool and the old connections idle out. Build the absolute URI from live configuration at the moment of the call.
## 2. Startup wiring
The public startup API is three methods; the transport seam is mandatory.
```csharp
// 1. the transport seam: mandatory. Without it, the first client resolution throws
// "You must register an implementation for INetworkMap ...".
// TryAdd, not Add: in production nothing else registers the map (TryAdd == Add), and if this
// composition ever runs inside a distributed-test host, the framework has ALREADY registered the
// virtual network map - TryAdd yields to it, a plain Add would clobber the simulation.
services.TryAddSingleton(); // namespace SnowBank.Networking
// 2. the defaults hook: routes EVERY factory client through the map, so a plain AddHttpClient
// needs no enrollment. The configure sets the baseline for every client.
services.AddBetterHttpClientDefaults(options =>
{
options.DefaultRequestHeaders.UserAgent = [ new ProductInfoHeaderValue("AcmeApp", "5.2") ];
});
// 3. any named clients that need their own policy (certificates, credentials, handlers)
services.AddBetterHttpClient("Catalog", options =>
{
options.AcceptSelfSignedServerCertificates();
});
Notes:
AddBetterHttpClientDefaults(configure)is the one mandatory call. It installs aConfigureHttpClientDefaultshook that routes every factory client (named, typed viaAddHttpClient, keyed, or a plainAddHttpClient("x")) through the map, so a stock client needs no enrollment and a distributed-test host sandboxes every factory client by construction. The globalconfiguresets the baseline (transport, default headers, TLS trust, credentials) for every client.AddBetterHttpClient("name", configure)adds a named client whose per-client options override that baseline. A client with no BetterHttp-specific policy does not need it: a plainservices.AddHttpClient("weather", c => c.Timeout = ...)is already fully enrolled by the defaults hook.- Both are safe to call more than once: each
configurecomposes (in order), and the defaults hook installs once. - Registering the same name twice composes: both configure callbacks run, so several call sites can contribute policies to one name.
- The name
"SnowBank.Networking.Http.BetterHttpClient"(BetterHttpClientExtensions.DefaultClientName) is reserved for the default client. - Inside a distributed test you do not wire
INetworkMap: the framework registers the virtual network map in every simulated host (see section 9).
AddBetterHttpClient(name, ...) returns an IBetterHttpClientBuilder. It derives from the native IHttpClientBuilder, so the standard registration extensions chain on, and BetterHttp-specific extensions target it:
services.AddBetterHttpClient("Catalog", options => options.AcceptSelfSignedServerCertificates())
.AddHttpMessageHandler() // an application DelegatingHandler
.AddAsKeyed(); // keyed injection, Microsoft.Extensions.Http 9.0+
The old no-name AddBetterHttpClient(configure) overload stays retired ([Obsolete(error: true)]): it wired only the default client, so a stock AddHttpClient escaped the map. Call AddBetterHttpClientDefaults(configure).
3. Getting a client: every kind is equivalent
Every kind of client gives the same policy, because the policy lives in the chain. Pick by consumer lifetime:
| Consumer | Client kind | API | |---|---|---| | Request-scoped (controllers, per-request services) | typed or keyed client | AddHttpClient() (ctor-injected HttpClient), or .AddAsKeyed() then [FromKeyedServices("Catalog")] HttpClient | | Singletons, static-cached factories | IHttpClientFactory | factory.CreateClient("Catalog"), using var per operation | | Third-party libs that build their own client (gRPC, SignalR, Kiota) | IHttpMessageHandlerFactory | factory.CreateHandler("Catalog") returns the bare pooled chain |
All four carry the name's full policy, including packet capture inside tests. A plain HttpClient is enough: a service can depend on HttpClient (typed client) and receive a fully configured instance.
// a singleton that talks to Catalog:
public sealed class CatalogGateway
{
public CatalogGateway(IHttpClientFactory clients) => this.Clients = clients;
private IHttpClientFactory Clients { get; }
public async Task FetchAsync(Uri origin, CancellationToken ct)
{
using var client = this.Clients.CreateClient("Catalog");
var request = client.CreateGetRequest(new Uri(origin, "/api/catalog"));
return await client.SendAsync(request, async ctx =>
{
ctx.EnsureSuccessStatusCode();
return await ctx.ReadAsJsonAsync();
}, ct);
}
}
Holding one client long-lived is correct (late binding keeps routing it against the live network), but the per-operation CreateClient idiom stays the convention for long-lived services: creation is cheap and Dispose never closes sockets.
> Legacy: IBetterHttpClientFactory.CreateClient(...) and the BetterHttpClient wrapper still resolve, now under [Obsolete] warnings. They are no longer the primary way to get a client. Section 10 lists the replacements.
4. Making requests
Add using SnowBank.Networking.Http; - the request API lives in extension methods on HttpClient, so it works on any client:
- Request builders:
client.CreateGetRequest(path),CreatePostRequest(path, content),CreatePutRequest,CreatePatchRequest,CreateDeleteRequest,CreateHeadRequest,CreateOptionsRequest,CreateTraceRequest(each withstringorUrioverloads, resolved againstBaseAddress). - The send lifecycle:
client.SendAsync(request, handler, ct)where the handler receives aBetterHttpClientContextwhile the response is still open:
var result = await client.SendAsync(
client.CreateGetRequest("/api/catalog"),
async (ctx) =>
{
ctx.EnsureSuccessStatusCode();
return await ctx.ReadAsJsonAsync(); // CrystalJson deserialization
},
ct);
BetterHttpClientContext carries Request, Response, the DI Services, the injected Clock, a per-request State bag (how stages coordinate), and helpers: EnsureSuccessStatusCode(), ReadAsJsonAsync() / ReadAsJsonObjectAsync() / ReadAsJsonArrayAsync() / ReadAsJsonAsync().
Cancellation tokens are required across this stack, never optional: pass the caller's real token (HttpContext.RequestAborted, a BackgroundService's stoppingToken, ...).
> What a plain GetAsync gets. Since 7.4.4 the in-chain BetterHttpPipelineHandler runs the name's request stage (credentials, hooks, default headers) even for a bare client.GetAsync(...). So a signing credential on the name signs a plain GetAsync too. The SendAsync(request, ctx => ...) form adds the context callback, the State bag, and the JSON helpers, and lets you read the response while the stack still owns disposal, which is why the callback processes the response rather than returning it.
5. Options and scopes
| Scope | Type | Where | What belongs there | |---|---|---|---| | Per name (client policy) | BetterHttpClientOptions | AddBetterHttpClient(name, options => ...) at startup | TLS/certificates, proxy, cookies, credentials, hooks, delegating handlers, default headers | | Configuration override | the BetterHttp section | AddBetterHttpClientConfiguration(configuration) | the ops-safe subset (section 6), applied after code, last word | | Per call (typed protocols) | the protocol's options | protocolFactory.CreateClient(uri, o => ...) | protocol/client behavior only |
The rule: client policy lives on the name, at startup. A per-call configure that touches client policy (a TLS callback, a delegating handler) cannot reach the shared pooled transport, and ignoring it would be a silent security break, so it throws, naming the offending member. The per-call side may set client behavior only: default headers, request options, hooks, Timeout, and per-request-only credentials (a message signer stamping a different identity per client); all of them run per request, in the chain.
Useful BetterHttpClientOptions members:
DefaultRequestHeaders(aBetterDefaultHeaders; includesUserAgent),Cookies,Credentials,DefaultProxyCredentials.Hooks(IBetterHttpHooks),HandlersandWithDelegatingHandler()for classicDelegatingHandlers. For per-request stages, prefer a standardDelegatingHandleradded with.AddHttpMessageHandler()on the builder.- TLS:
ServerCertificateCustomValidationCallback(connection-shaped:(cert, chain, errors) => bool, no request argument, it validates a connection and maps directly onto the socket transport'sSslOptions),ClientCertificates,ClientCertificateOptions,CheckCertificateRevocationList. - TLS helpers, in decreasing order of preference:
TrustServerCertificates(params X509Certificate2[] roots)- pin known roots;AcceptSelfSignedServerCertificates()- accept an otherwise-valid self-signed leaf (typical for appliances);DangerousAcceptAnyServerCertificate()- accept everything; test/lab only, the name is the warning, and it is[Obsolete]so the call site must acknowledge it with a#pragma.
> Retired scope: the BetterHttpShellOptions override (passed to the old factory.CreateClient(baseAddress, shell, name)) is gone. Put policy on the name, or set it on the request itself.
6. Binding options from configuration
AddBetterHttpClientConfiguration(configuration, sectionName = "BetterHttp") registers a configuration override layer. The section is a pure override: when it is absent, the code-configured behavior runs unchanged.
services.AddBetterHttpClientConfiguration(builder.Configuration); // reads the "BetterHttp" section
"BetterHttp": {
"Defaults": {
"AutomaticDecompression": "All"
},
"Clients": {
"Catalog": {
"Timeout": "00:00:30",
"Tls": { "Mode": "AcceptSelfSigned" }
}
}
}
Defaultsoverrides the global baseline for every client;Clients:overrides one named client. Both apply after the code layers, so configuration has the last word.- Only the operation-safe subset binds:
Timeout,AllowAutoRedirect,AutomaticDecompression, andTls:Mode(System,AcceptSelfSigned,AcceptAny). Credentials, hooks, handlers, and TLS callbacks are code-only by construction, they cannot be reached from a string. - A knob can carry
"inherit"to cancel every override below the global layers, so the effective value falls back to the code-global baseline (for a knob inClients:, this also cancels that client's own code configure). - Repeated calls compose, applied in registration order.
7. Porting legacy HttpWebRequest code (the AddFooClient recipe)
Where legacy .NET Framework members go:
| Legacy (HttpWebRequest / ServicePointManager) | Now | |---|---| | WebRequest.Create(url) per call | one injected client; absolute URI per request | | req.UserAgent = "AcmeApp/5.2" | options.DefaultRequestHeaders.UserAgent on the name | | req.ServerCertificateValidationCallback = ... => true | AcceptSelfSignedServerCertificates() (or TrustServerCertificates; reserve DangerousAcceptAnyServerCertificate for tests) | | req.Headers.Add("Authorization", ...) | per request (request.Headers.Authorization = ...), or a credential on the name | | req.GetResponse() + StreamReader (sync) | await client.SendAsync(request, ctx => ..., ct) | | req.Proxy, req.Credentials, req.CookieContainer | DefaultProxyCredentials / Credentials / Cookies on the name | | ServicePointManager global state | per-client options (there is no process-global mutable state) |
Before (net472):
public class CatalogGateway
{
public string FetchCatalog(string server, string token)
{
var req = (HttpWebRequest) WebRequest.Create($"https://{server}/api/catalog");
req.UserAgent = "AcmeApp/5.2";
req.Headers.Add("Authorization", "Bearer " + token);
req.ServerCertificateValidationCallback = (s, cert, chain, errors) => true;
using var resp = (HttpWebResponse) req.GetResponse();
using var reader = new StreamReader(resp.GetResponseStream());
return reader.ReadToEnd();
}
}
After (net8+/net10), a typed client plus its registration extension:
public sealed class CatalogGateway
{
public CatalogGateway(HttpClient client) => this.Client = client;
private HttpClient Client { get; }
public async Task FetchCatalogAsync(Uri server, string token, CancellationToken ct)
{
var request = this.Client.CreateGetRequest(new Uri(server, "/api/catalog"));
request
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [SnowBankSDK](https://github.com/SnowBankSDK)
- **Source:** [SnowBankSDK/foundationdb-dotnet-client](https://github.com/SnowBankSDK/foundationdb-dotnet-client)
- **License:** BSD-3-Clause
- **Homepage:** https://snowbanksdk.github.io/foundationdb-dotnet-client/
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.