Pacem.Net.Http
1.0.0-peano
See the version list below for details.
dotnet add package Pacem.Net.Http --version 1.0.0-peano
NuGet\Install-Package Pacem.Net.Http -Version 1.0.0-peano
<PackageReference Include="Pacem.Net.Http" Version="1.0.0-peano" />
<PackageVersion Include="Pacem.Net.Http" Version="1.0.0-peano" />
<PackageReference Include="Pacem.Net.Http" />
paket add Pacem.Net.Http --version 1.0.0-peano
#r "nuget: Pacem.Net.Http, 1.0.0-peano"
#:package Pacem.Net.Http@1.0.0-peano
#addin nuget:?package=Pacem.Net.Http&version=1.0.0-peano&prerelease
#tool nuget:?package=Pacem.Net.Http&version=1.0.0-peano&prerelease
Pacem.Net.Http
A small toolbox for outgoing HTTP calls on top of IHttpClientFactory: the boring, easy-to-get-wrong parts of
talking to OAuth-protected and key-protected APIs, done once.
- Bearer tokens attached by a
DelegatingHandler, renewed and retried once when the server refuses them:client_credentialsfor service-to-service calls (cached, renewed underneath, no HttpContext needed),- the signed-in user's token, forwarded from ASP.NET Core,
- rotating refresh-token chains (one consent, then refresh forever; the rotated pair always reaches your store).
- API keys (
X-Api-Key,Ocp-Apim-Subscription-Key…) set per request. RestClient: a JSON client with uri templates and failures thrown asHttpApiException, usable as a base for typed clients.
Everything composes with the standard handlers, e.g. AddStandardResilienceHandler() from
Microsoft.Extensions.Http.Resilience. Public types live in the Pacem.Net.Http namespace, DI extensions in
Microsoft.Extensions.DependencyInjection.
Install
dotnet add package Pacem.Net.Http
Targets net10.0. Depends on the ASP.NET Core shared framework (for user-token forwarding) and on
Pacem.IdentityModel for the OAuth protocol calls.
Service-to-service: client credentials
"Erp": {
"BaseUrl": "https://erp.example.com/api/",
"ClientCredentials": {
"DiscoveryDocumentUrl": "https://id.example.com/acme/.well-known/openid-configuration",
"ClientId": "erp-worker",
"ClientSecret": "<secret>",
"Scope": "erp.read erp.write"
}
}
public static class Program
{
public static void Main(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
IConfigurationSection erp = builder.Configuration.GetSection("Erp");
builder.Services.AddHttpClient<ErpClient>(client => client.BaseAddress = new Uri(erp["BaseUrl"]!))
.AddClientCredentialsToken(options => erp.GetSection("ClientCredentials").Bind(options))
.AddStandardResilienceHandler();
builder.Services.AddHostedService<InvoiceSyncWorker>();
builder.Build().Run();
}
}
The token is requested on the first call, cached per discovery url + client + scope, shared by concurrent callers and
renewed shortly before it expires (ClientCredentialsOptions.Skew, 30s by default). The token endpoint is read from the
discovery document; ClientAuthenticationMethod picks client_secret_basic (default) or client_secret_post.
Typed clients: RestClient
Derive from RestClient and every call is one line:
public sealed class ErpClient(HttpClient http) : RestClient(http)
{
public Task<InvoiceDto?> FindInvoiceAsync(string erpId, int number, CancellationToken cancellationToken)
=> GetOrNullAsync<InvoiceDto>("erp/{erp}/invoices/{number}", new { erp = erpId, number }, cancellationToken);
public Task<InvoiceDto?> CreateInvoiceAsync(string erpId, InvoiceDraft draft, CancellationToken cancellationToken)
=> PostAsync<InvoiceDto>("erp/{erp}/invoices", draft, new { erp = erpId }, cancellationToken);
public Task CancelInvoiceAsync(string erpId, int number, CancellationToken cancellationToken)
=> DeleteAsync("erp/{erp}/invoices/{number}", new { erp = erpId, number }, cancellationToken);
}
| Call | Success | Notes |
|---|---|---|
GetAsync<T> |
the JSON body; default for 204 or an empty body |
|
GetOrNullAsync<T> |
same | 404 is default, not a failure |
GetStringAsync |
the body as text | |
PostAsync, PutAsync, PatchAsync |
nothing, or the JSON answer with <TResult> |
a null body sends no content |
DeleteAsync |
nothing |
Every other status throws HttpApiException. Http exposes the underlying HttpClient for anything else (streams,
headers, other content types). JSON uses JsonSerializerDefaults.Web unless RestClientOptions says otherwise.
Uri templates
{name} placeholders take the matching parameter, escaped; every other parameter goes to the query string. Parameters
are an object (its public properties) or a dictionary.
HttpRequestUri.Build("blog/{blog}/article/{id}", new { blog = 3, id = "a b", edit = true });
// blog/3/article/a%20b?edit=True
HttpRequestUri.Build("users", new { role = new[] { "admin", "editor" }, q = (string?)null });
// users?role=admin&role=editor (null left out, sequences repeat their key)
Values are formatted with the invariant culture, dates as ISO 8601. The same HttpRequestUri.Build is used by every
RestClient call.
Named clients
When a dedicated class is overkill, register named RestClients and get them from IRestClientFactory. Each call wraps a
fresh HttpClient, so singletons can ask for one per call:
services.AddRestClient("erp", client => client.BaseAddress = new Uri("https://erp.example.com/api/"),
options => options.JsonSerializerOptions = new JsonSerializerOptions()) // e.g. PascalCase DTOs
.AddClientCredentialsToken(options => erp.GetSection("ClientCredentials").Bind(options));
public sealed class InvoiceReport(IRestClientFactory rest)
{
public Task<List<InvoiceDto>?> GetOpenAsync(string erpId, CancellationToken cancellationToken)
=> rest.CreateClient("erp").GetAsync<List<InvoiceDto>>("erp/{erp}/invoices", new { erp = erpId, open = true }, cancellationToken);
}
Errors
try
{
await erp.CreateInvoiceAsync("acme", draft, stoppingToken);
}
catch (HttpApiException exc) when (exc.StatusCode == HttpStatusCode.Conflict)
{
logger.LogWarning("Invoice already there: {body}", exc.ResponseBody);
}
HttpApiException derives from HttpRequestException and carries StatusCode, ReasonPhrase, Method, RequestUri
and the start of the body (ResponseBody, up to 4096 characters). The same checks are available on any
HttpResponseMessage: EnsureSuccessAsync(), ReadJsonOrDefaultAsync<T>(), ReadJsonOrNullOnNotFoundAsync<T>().
Per-request credentials and the signed-in user
A multi-tenant web app may call an API with a different client per tenant (resolved from the current request), and on behalf of the signed-in user for writes:
services.AddRestClient("api", ConfigureBaseAddress)
.AddClientCredentialsToken(ResolveTenantClientAsync);
services.AddRestClient("api.user", ConfigureBaseAddress)
.AddUserAccessToken();
private static async ValueTask<ClientCredentialsOptions?> ResolveTenantClientAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var httpContext = services.GetRequiredService<IHttpContextAccessor>().HttpContext;
if (httpContext is null)
{
// no request: anonymous
return null;
}
var tenants = httpContext.RequestServices.GetRequiredService<ITenantClients>();
var client = await tenants.FindByRequestAsync(httpContext, cancellationToken);
if (client is null)
{
return null;
}
return new ClientCredentialsOptions
{
DiscoveryDocumentUrl = client.DiscoveryDocumentUrl,
ClientId = client.ClientId,
ClientSecret = client.ClientSecret,
};
}
- The resolver runs on every request; returning
nullsends the request withoutAuthorization. AddUserAccessToken()forwards theaccess_tokensaved by the authentication handler (SaveTokens = true). With no HttpContext, no signed-in user or no saved token the request goes out anonymously.- A refused user token is renewed by the registered
IUserAccessTokenRenewer, if any, and the call retried once; the renewed token serves the rest of the request. Whoever owns the session provides the renewer: Pacem.Authentication.OpenIdConnect registers one that refreshes the session and rewrites its cookie. Without a renewer the401is returned as it is.
public sealed class MySessionRenewer : IUserAccessTokenRenewer
{
public async ValueTask<string?> RenewAsync(HttpContext httpContext, string rejected, CancellationToken cancellationToken)
{
// refresh the user's session; null when it can't be done (e.g. httpContext.Response.HasStarted)
…
}
}
services.AddSingleton<IUserAccessTokenRenewer, MySessionRenewer>();
API keys
services.AddHttpClient<StoreClient>((sp, client) => client.BaseAddress = new Uri(GetStoreOptions(sp).BaseUrl))
.AddApiKey("X-Api-Key", sp => GetStoreOptions(sp).ApiKey) // read on every request; null/empty sends none
.AddStandardResilienceHandler();
AddApiKey(headerName, key) also takes a fixed string. The header is set on each request, never on the client's shared
DefaultRequestHeaders.
Rotating refresh tokens
For cloud APIs where a user consents once (authorization code) and the app then keeps a refresh token alive, often rotated on every use. One call registers the chain, its store and its endpoint, and puts the bearer handler in front of the client:
"Thermostat": {
"ClientId": "<client id>",
"ClientSecret": "<secret>",
"RedirectUri": "https://home.example.com/thermostat/consent",
"SubscriptionKey": "<key>"
}
IConfigurationSection thermostat = builder.Configuration.GetSection("Thermostat");
builder.Services.AddHttpClient<ThermostatClient>(client => client.BaseAddress = new Uri("https://api.thermostat.example/v2/"))
.AddApiKey("Ocp-Apim-Subscription-Key", thermostat["SubscriptionKey"])
.AddRefreshTokenChain<FileTokenStore>("thermostat", chain =>
{
thermostat.Bind(chain);
chain.TokenEndpointUrl = "https://login.thermostat.example/token";
chain.ClientAuthenticationMethod = KnownClientAuthenticationMethods.ClientSecretPost;
chain.Skew = TimeSpan.FromMinutes(5);
});
- Endpoint:
TokenEndpointUrlposts the grant there (providers without a discovery document); otherwiseDiscoveryDocumentUrlreads it from the discovery document. A customITokenEndpointgoes in withAddRefreshTokenChain<TStore, TEndpoint>(…). - Store:
InMemoryTokenStoreunless you passTStore. ImplementITokenStore(LoadAsync/SaveAsync) to survive restarts; it's a keyed singleton under the chain name, so it can take a[ServiceKey] stringconstructor parameter. - Sharing: several clients can use the same chain by name. Their
configurecallbacks add up, so it may be omitted on all but one:.AddRefreshTokenChain("thermostat"). The first registration of a name decides its store and endpoint.
The chain itself is a keyed singleton. Start it from the consent callback:
app.MapGet("/thermostat/consent", RedeemAsync);
private static async Task<IResult> RedeemAsync(string code, [FromKeyedServices("thermostat")] RefreshTokenChain chain,
CancellationToken cancellationToken)
{
await chain.RedeemAuthorizationCodeAsync(code, cancellationToken: cancellationToken);
return Results.Redirect("/");
}
…or seed it with a refresh token obtained elsewhere: await chain.ImportAsync(TokenSet.FromRefreshToken(token))
(refreshed at once: with rotating providers the copy you imported is dead afterwards). ExchangeAuthorizationCodeAsync
redeems a code without storing the pair, e.g. to hand it to another host; PeekAccessToken() reads the token in memory
without refreshing.
From then on every call refreshes as needed: when the access token is about to expire, or when the server refuses it.
One caller refreshes at a time. Once a refresh request is out it is not cancelled, so the rotated pair always reaches the
store; if saving fails, it's logged as critical and kept in memory. A broken chain (nothing stored, or invalid_grant)
surfaces from the HTTP call:
try
{
var status = await thermostats.GetStatusAsync(plantId, moduleId, cancellationToken);
}
catch (ReauthorizationRequiredException)
{
// ask the user to consent again
}
When "refused" isn't just a 401
By default only a 401 triggers the refresh-and-retry. Some APIs answer 403 and explain in the body; the body of a
failed response is buffered, so the check can read it and the caller can read it again:
.AddRefreshTokenChain<FileTokenStore>("weather", chain =>
{
weather.Bind(chain);
chain.TokenEndpointUrl = "https://api.weather.example/oauth2/token";
chain.Handler.IsRefused = IsTokenRefusedAsync;
});
private static async ValueTask<bool> IsTokenRefusedAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.StatusCode is not (HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden))
{
return false;
}
string body = await response.Content.ReadAsStringAsync(cancellationToken);
// e.g. {"error":{"code":3,"message":"Access token expired"}}
return body.Contains("\"code\":3", StringComparison.Ordinal);
}
The same IsRefused is available to every token handler through the optional configureHandler argument of
AddClientCredentialsToken and AddBearerToken.
Your own token source
Anything that can hand out a token plugs into the same handler:
public sealed class VaultTokenSource(IVault vault) : IAccessTokenSource
{
public async ValueTask<string?> GetAccessTokenAsync(string? rejected, CancellationToken cancellationToken)
{
// rejected: the token the server just refused, if any; hand out another one, or the same to stop retrying
return await vault.GetSecretAsync("api-token", forceReload: rejected is not null, cancellationToken);
}
}
services.AddHttpClient<ReportsClient>(…)
.AddBearerToken<VaultTokenSource>();
Returning null sends the request without Authorization.
How the handlers behave
- The request content is buffered before the first send, so a retry can send it again.
- At most one retry per request, and only when the source hands out a different token.
- Add the authentication handlers first and
AddStandardResilienceHandler()last. - Nothing calls
BuildServiceProvider()during registration; registrations are idempotent (TryAdd), andAddPacemIdentityModelClient()is called for you unless anIClientFactoryis already registered.
License
Apache-2.0.
| 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
- Pacem.IdentityModel (>= 1.0.0-peano)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Pacem.Net.Http:
| Package | Downloads |
|---|---|
|
Pacem.Domotics.Legrand
Pacem domotics implementation for the Legrand Smarther v2.0 cloud API, i.e. BTicino Smarther (X8000). |
|
|
Pacem.Authentication.OpenIdConnect
OpenID Connect sign-in for ASP.NET Core apps in the Pacem ecosystem: cookie session, clients resolved per request (multi-tenant), session token refresh and on-demand renewal for downstream calls. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-ricci | 0 | 10/28/2026 |
| 1.0.0-peano | 37 | 10/27/2026 |
| 1.0.0-maiorana | 42 | 10/27/2026 |
| 0.10.19 | 41 | 10/27/2026 |
| 0.10.19-galilei | 45 | 10/27/2026 |
| 0.10.19-fermi | 45 | 10/27/2026 |
| 0.10.19-degiorgi | 56 | 10/26/2026 |