HTTP Integration
The Kevlar.Extensions.Http package plugs shields into HttpClientFactory as a DelegatingHandler, with transient-fault handling and Retry-After support built in.
dotnet add package Kevlar.Extensions.Http
The one-liner
using Microsoft.Extensions.DependencyInjection;
services.AddHttpClient("api")
.AddStandardShield();
AddStandardShield wires up the pipeline you'd have built anyway (outermost first):
HTTP registration extensions live in Microsoft.Extensions.DependencyInjection, which ASP.NET
Core projects import implicitly. Import Kevlar.Extensions.Http only when using HTTP options or
runtime types such as HttpShield.
- 30s total timeout around everything
- 3 jittered retries (exponential from 250ms, capped 10s) — honouring
Retry-Afterheaders and disposing superseded responses - Circuit breaker — sampling mode: opens at a 50% failure ratio over a 30s window (minimum 10 calls), breaks for 15s
- 10s attempt timeout per individual try
The standard registration disables HttpClient.Timeout; its attempt and total timeout strategies
own timeout behavior inside the retry boundary. Configure AttemptTimeout and TotalTimeout
instead. If you configure HttpClient.Timeout on the same builder, call AddStandardShield after
that configuration.
Customize those stages without rebuilding the pipeline:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
services.AddHttpClient("api")
.AddStandardShield(options =>
{
options.TotalTimeout.Timeout = TimeSpan.FromSeconds(20);
options.Retry.MaxRetries = 2;
options.CircuitBreaker.FailureRatio = 0.25;
options.CircuitBreaker.HandlesResult = response =>
response.StatusCode == HttpStatusCode.ServiceUnavailable;
options.ConcurrencyLimit = new ConcurrencyLimitOptions
{
MaxConcurrency = 100,
QueueLimit = 20,
};
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5);
options.Handler.ContentReplayPolicy = HttpContentReplayPolicy.Buffer;
options.Handler.MaxBufferSize = 256 * 1024;
});
StandardHttpShieldOptions exposes the total timeout, typed retry and circuit-breaker options,
optional concurrency limiter, attempt timeout, and handler replay/routing options. Replacing
Retry still honours Retry-After by default; set UseRetryAfterHeader = false to opt out. Set
either timeout's Timeout to Timeout.InfiniteTimeSpan to omit that stage. A finite attempt timeout
cannot exceed a finite total timeout. Invalid strategy values fail while the registration is built;
handler replay/routing values fail when
HttpClientFactory builds its handler pipeline, before a request is sent.
The breaker is a CircuitBreakerOptions<HttpResponseMessage>, so HandlesResult can replace the
standard transient-result clause for that stage without changing retry handling.
For dependency-aware setup, use the service-provider overload:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
services.AddSingleton(new ConcurrencyLimitOptions
{
MaxConcurrency = 100,
QueueLimit = 20,
});
services.AddHttpClient("api")
.AddStandardShield((serviceProvider, options) =>
{
options.ConcurrencyLimit =
serviceProvider.GetRequiredService<ConcurrencyLimitOptions>();
});
The one-argument callback runs during registration. The service-provider callback runs lazily on first client creation using the application service provider. Both build one shield for the named client registration, so breaker, limiter, and other strategy state survives handler rotation.
Configuration and reload
Pass an IConfiguration section to bind the standard pipeline and reload it when the section's
change token fires:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Http:Api:TotalTimeout"] = "00:00:20",
["Http:Api:Retry:MaxRetries"] = "2",
["Http:Api:Retry:Backoff"] = "Exponential",
["Http:Api:Retry:BaseDelay"] = "00:00:00.100",
["Http:Api:Retry:Jitter"] = "Equal",
["Http:Api:AttemptTimeout"] = "00:00:05",
["Http:Api:Handler:MaxBufferSize"] = "262144",
})
.Build();
services.AddHttpClient("api")
.AddStandardShield(
configuration.GetSection("Http:Api"),
onReloadFailure: exception =>
Console.Error.WriteLine(exception.Message));
Timeouts accept either a scalar (TotalTimeout) or the options-shaped
TotalTimeout:Timeout key. Retry keys are MaxRetries, Backoff (None, Constant, Linear,
or Exponential), BaseDelay, Factor, Jitter (None, Equal, Full, or Decorrelated),
BackoffMaxDelay, and MaxDelay; UseRetryAfterHeader is a root standard-shield key. Circuit
breaker, concurrency-limit, handler, routing, and endpoint keys match their public option-property
names. Endpoint entries accept either a URI scalar or Uri plus optional Weight children.
Configuration is applied first. The service-provider callback overload runs afterward, so DI values and delegates can deliberately override bound values:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Retry:MaxRetries"] = "2",
})
.Build();
services.AddLogging();
services.AddHttpClient("api")
.AddStandardShield(configuration, (serviceProvider, options) =>
{
var logger = serviceProvider
.GetRequiredService<ILoggerFactory>()
.CreateLogger("HttpResilience");
options.Retry.OnRetry = retry =>
{
logger.LogWarning("Retry {AttemptNumber}", retry.AttemptNumber);
return default;
};
});
Each request captures one immutable shield-and-handler snapshot. A valid reload builds the whole
replacement before publishing it atomically; in-flight requests finish on their original snapshot.
Invalid binding or validation keeps the last valid snapshot and calls onReloadFailure with the
full configuration path. A successful reload starts fresh breaker, limiter, and endpoint-local
state. HttpClientFactory handler rotation reuses the current snapshot and does not rerun the
service-provider callback. The application service provider owns the reload subscription.
Hedging uses the same reload contract. Its keys follow the nested
StandardHedgeShieldOptions shape. Routing is optional; without it, attempts keep the request's
authority:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Hedge:MaxHedgedAttempts"] = "1",
["Hedge:Delay"] = "00:00:00.500",
["Routing:SelectionMode"] = "Weighted",
["Routing:Endpoints:0:Uri"] = "https://api-a.example",
["Routing:Endpoints:0:Weight"] = "3",
["Routing:Endpoints:1:Uri"] = "https://api-b.example",
})
.Build();
services.AddHttpClient("routed")
.AddStandardHedgeShield(configuration);
Bring your own pipeline
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
services.AddHttpClient("api")
.AddShield(
HttpShield.WhenTransient()
.Retry(o =>
{
o.MaxRetries = 4;
o.DelayGenerator = HttpShield.RetryAfter;
})
.CircuitBreaker(o => o.FailureRatio = 0.5),
new ShieldHttpHandlerOptions
{
ContentReplayPolicy = HttpContentReplayPolicy.Buffer,
MaxBufferSize = 1024 * 1024,
});
You can also grab that exact shield directly with HttpShield.Standard().
HttpShield.WhenTransient()
Starts a typed Shield<HttpResponseMessage> builder with the standard transient-fault handling clause:
HttpRequestExceptionHttpClient.Timeout(TaskCanceledExceptionwith an innerTimeoutException)- attempt timeouts (
TimeoutExceededException) - HTTP 500–599 responses (numeric status codes outside that range are not treated as 5xx)
- HTTP 408 (Request Timeout)
- HTTP 429 (Too Many Requests)
Use HttpShield.IsTransient(response) for the status-code test alone. Use
HttpShield.IsTransientException(exception, callerCancellationToken) when classifying an exception
outside a shield; caller cancellation is never transient. On the netstandard2.0 asset, a bare
TaskCanceledException with no cancellable token is treated as the legacy HttpClient.Timeout
shape.
HttpShield.RetryAfter
A DelayGenerator for retry options: when the failed response carries a Retry-After header
(delta or date form), the retry waits what the server asked for, capped at 30 seconds by default.
The server's suggestion is used only when it's longer than the computed backoff; no header →
normal backoff applies. It returns a completed ValueTask<TimeSpan?>, so it binds directly as a
method group (options.DelayGenerator = HttpShield.RetryAfter) and works with synchronous
Execute. Use HttpShield.RetryAfter(maxDelay) to choose another cap.
The standard shield composes this with a custom Retry.DelayGenerator and uses the longer result,
awaiting the custom generator when it yields. Set UseRetryAfterHeader to false when the custom
generator must have exclusive control.
The standard shield caps every retry delay at 10 seconds, so one excessive server suggestion cannot impose an unbounded wait. Custom shields can cap server-suggested delays directly:
using Kevlar.Extensions.Http;
var shield = HttpShield.WhenTransient()
.Retry(options =>
{
options.DelayGenerator = HttpShield.RetryAfter(TimeSpan.FromSeconds(5));
});
Registering a shield built elsewhere
using Kevlar.Extensions.DependencyInjection;
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
services.AddHttpClient("api")
.AddShield("downstream");
The named overload resolves IKevlarRegistry.GetShield<HttpResponseMessage> for every request, so
reload-aware registrations and dynamic registry replacements are observed without rebuilding the
HttpClient handler pipeline. A missing name throws KeyNotFoundException on the first request.
AddShield also accepts a shield instance, an IServiceProvider factory, or a per-request selector.
Call RemoveAllShields() after the relevant registrations to remove Kevlar handlers from that named
client while leaving unrelated delegating handlers intact. It also removes the
HttpClient.Timeout = Timeout.InfiniteTimeSpan overrides installed by preceding standard shields,
so an earlier custom client timeout (or the normal default) remains effective.
Per-request options
Attach execution properties, select a shield, allow or suppress replay, or link another cancellation token without changing the named client's defaults:
using Kevlar.Extensions.Http;
var tenantKey = new KevlarKey<string>("tenant");
using var request = new HttpRequestMessage(HttpMethod.Post, "orders")
.WithKevlarProperties(properties => properties.Set(tenantKey, "north"))
.WithShieldName("orders-write")
.DisableReplay()
.WithKevlarCancellationToken(cancellationToken);
using var response = await httpClient.SendAsync(request, cancellationToken);
The property initializer runs once for the outer request execution and once for each separately
executed endpoint-local shield. Retries reuse their context, while hedges copy the initialized
properties into forked contexts instead of rerunning the initializer. Pooled properties are cleared
after execution, so values do not leak to later requests. DisableReplay and AllowReplay affect
only this request, and the last call wins. DisableReplay keeps any request, including a GET,
single-attempt. AllowReplay lets one known-idempotent POST, PATCH, or custom-method request retry
or hedge; content must still satisfy the normal replay-safety rules. See
method safety for the reasoning and the idempotency-key pattern.
Choose one shield per request with the selector overload. Selection happens once, before the
shield executes. A direct .WithShield(shield) request override takes precedence over the
selector; ShieldName is metadata for selectors and has no global lookup behavior:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
var readShield = HttpShield.WhenTransient().Retry(3);
var writeShield = HttpShield.WhenTransient().Retry(0);
services.AddHttpClient("api")
.AddShield((request, serviceProvider) =>
KevlarHttp.GetRequestOptions(request).ShieldName == "orders-write"
? writeShield
: readShield);
For isolated, bounded state per request key, connect a PartitionedShield
directly:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
var tenantShields = new PartitionedShield<string, HttpResponseMessage>(
_ => HttpShield.WhenTransient()
.CircuitBreaker(consecutiveFailures: 5, breakDuration: TimeSpan.FromSeconds(30)));
services.AddHttpClient("tenant-api")
.AddShield(
tenantShields,
request => request.Headers.GetValues("X-Tenant").Single());
On .NET 8 and later, KevlarHttp.RequestOptions is the public typed
HttpRequestOptionsKey<KevlarRequestOptions> for direct HttpRequestMessage.Options access.
GetRequestOptions and the request extensions also work through HttpRequestMessage.Properties
when consuming the netstandard2.0 asset. Built-in replay clones carry the same request-options
object. A custom RequestFactory creates the complete request and must copy any desired options.
Safe request replay
Every retry and hedge needs a fresh HttpRequestMessage: .NET sends a message once, and one-shot
content is consumed by the first send. Kevlar rebuilds the message for you. Two independent checks
then decide whether an additional attempt may be sent:
- Can the message be rebuilt? A mechanical question about the content. Kevlar answers it automatically for most requests.
- Is it safe to send twice? A semantic question about the operation. Only the caller can answer it for POST, PATCH, and custom methods.
When method replay is suppressed, or NoBuffer rejects non-replayable content, the shield stays
single-attempt for that request instead of failing it. With Buffer, content that exceeds
MaxBufferSize or fails serialization throws HttpRequestReplayException before the first
transport attempt.
Rebuilding the message
The first no-routing attempt sends the caller's original request directly. Additional attempts use clones that preserve method, URI, HTTP version and version policy, request headers, request options, and content headers. The handler owns every clone and every nonselected response; the caller owns the original request and the returned response.
Content replay depends on ContentReplayPolicy:
NoBuffer(default) reuses inherently re-readable content such asByteArrayContent,StringContent,FormUrlEncodedContent, and ordinaryJsonContent. Positive-length content already loaded into its HTTP buffer is also reusable.JsonContentdeclared asIAsyncEnumerable<T>and one-shot content such asStreamContentare sent once; callLoadIntoBufferAsync()first, selectBuffer, or provide aRequestFactoryto replay them.Bufferserializes content once before sending, bounded byMaxBufferSize, then gives each attempt its ownByteArrayContent. Oversize or partial serialization fails before attempt 1.RequestFactorycreates a complete fresh request per attempt. Use it for one-shot streams, generated bodies, signatures, or other request state that cannot be cloned. Factory requests are disposed by the handler.
Method safety
GET, HEAD, OPTIONS, TRACE, PUT, and DELETE are idempotent by definition
(RFC 9110 §9.2.2), so Kevlar replays them
automatically. POST, PATCH, and custom methods are not, and a perfect clone does not change that:
the risk is the server executing the operation twice, not the client rebuilding the message. A
hedged POST /orders is two concurrent order creations unless the server deduplicates them, and
Kevlar cannot know whether it does. These methods therefore stay single-attempt until you opt in:
| Opt-in | Scope | Use when |
|---|---|---|
request.AllowReplay() | one request | This request is idempotent, typically because it carries an idempotency key. |
Handler.AllowUnsafeMethodReplay = true | every request on the client | The API deduplicates every write, or the client only sends idempotent unsafe-method requests. |
Handler.RequestFactory | every request on the client | Each attempt needs a freshly built request (streams, signatures, generated bodies). Building every attempt yourself counts as opt-in. |
Opting in does not skip the content check: a POST with one-shot content still needs Buffer,
LoadIntoBufferAsync(), or a RequestFactory. request.DisableReplay() forces any request,
including a GET, to stay single-attempt.
The recommended pattern for a retried or hedged write is an idempotency key that the server deduplicates. Clones preserve headers, so every attempt carries the same key, and a duplicate delivery becomes a harmless repeat rather than a second order:
using System.Net.Http.Json;
using Kevlar.Extensions.Http;
using var createOrder = new HttpRequestMessage(HttpMethod.Post, "orders")
{
Content = JsonContent.Create(new { Sku = "sku-1", Quantity = 2 }),
}.AllowReplay();
createOrder.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString("N"));
using var created = await httpClient.SendAsync(createOrder, cancellationToken);
Suppressed attempts
If method or content cannot be replayed safely, retry and hedging remain single-attempt: the
original response is returned or the original exception is rethrown without a retry delay or
callback. Other stages, including timeout, circuit breaker, and concurrency limiting, still observe
that attempt. A multi-attempt shield reports this decision once as the attempts_suppressed
telemetry event and log 1009, with reason replay_disabled, unsafe_method, or
non_replayable_content. The first unsafe_method suppression for a client is a Warning that
names the handler-wide and per-request opt-ins; later unsafe-method suppressions and other reasons
are Information. Telemetry uses the matching Warning or Information severity. The
kevlar.http.replay_suppressed counter carries the same bounded reason in
kevlar.suppression.reason. HttpRequestReplayException is reserved for configuration failures
such as a null factory result or content exceeding the requested buffer limit. Timeouts and caller
cancellation flow to every attempt and request factory.
Standard hedging
Hedge against the request's own authority without registration-time routing configuration:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
services.AddHttpClient("hedged")
.AddStandardHedgeShield();
The request path, query, and authority are preserved for every attempt. To route attempt 1, attempt 2, and so on across alternate authorities instead, configure endpoints explicitly:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
services.AddHttpClient("routed")
.AddStandardHedgeShield(options =>
{
options.Routing = new HttpEndpointRoutingOptions
{
SelectionMode = HttpEndpointSelectionMode.Weighted,
};
options.Routing.Endpoints.Add(new HttpEndpoint(new Uri("https://api-a.example"), weight: 3));
options.Routing.Endpoints.Add(new HttpEndpoint(new Uri("https://api-b.example"), weight: 1));
options.Hedge.MaxHedgedAttempts = 1;
options.Hedge.Delay = TimeSpan.FromMilliseconds(500);
options.Hedge.DelayGenerator = hedge => new(hedge.Elapsed < TimeSpan.FromSeconds(1)
? TimeSpan.FromMilliseconds(100)
: TimeSpan.Zero);
});
AddStandardHedgeShield installs a 30s total timeout and one additional hedged attempt (two total).
Each authority gets its own 50%-over-30s circuit breaker (minimum 10 attempts, 15s break) and 10s
attempt timeout. No concurrency limiter is installed by default. Set ConcurrencyLimit to a new
ConcurrencyLimitOptions instance to add an authority-local limiter; configure the remaining
defaults through TotalTimeout.Timeout, Hedge, CircuitBreaker, and AttemptTimeout.Timeout.
Request replay is configured through Handler (ContentReplayPolicy, MaxBufferSize,
AllowUnsafeMethodReplay, and RequestFactory); alternate endpoint authorities and ordering are
configured through Routing. An empty endpoint list uses the request's authority. POST, PATCH, and
custom methods still require the same explicit method-safety opt-in, handler-wide
or per request with AllowReplay(); registering the standard hedging pipeline does not make an
unsafe operation safe to repeat.
For a fully custom endpoint-aware pipeline, compose the outer and endpoint shields directly:
using Kevlar.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;
var routing = new HttpEndpointRoutingOptions
{
SelectionMode = HttpEndpointSelectionMode.Ordered,
ShieldFactory = endpoint => HttpShield.WhenTransient()
.CircuitBreaker(consecutiveFailures: 5, breakDuration: TimeSpan.FromSeconds(30)),
};
routing.Endpoints.Add(new HttpEndpoint(new Uri("https://api-a.example")));
routing.Endpoints.Add(new HttpEndpoint(new Uri("https://api-b.example")));
services.AddHttpClient("routed")
.AddShield(
HttpShield.WhenTransient().Hedge(1, delay: TimeSpan.Zero),
new ShieldHttpHandlerOptions { Routing = routing });
Ordered is deterministic configuration order. Weighted creates a deterministic weighted
permutation from Seed when provided, or from a random initial seed otherwise; a request visits
every configured endpoint before cycling. ShieldFactory
is cached by authority, so circuit-breaker and limiter state stays isolated per endpoint. Keep that
endpoint-local shield single-attempt (breaker, limiter, timeout); put retry or hedging in the outer
shield so every additional send goes through safe replay and routing.
Handler options are setup objects. ShieldDelegatingHandler snapshots their scalar values,
delegates, routing values, and endpoint list when the handler pipeline is created; the direct
AddShield(shield, options) overload snapshots at registration. Mutating those source objects later
does not reconfigure existing handlers. Use a configuration-backed standard registration when
runtime changes are required; each valid reload publishes a fresh complete pipeline snapshot.
Behaviour notes
- Superseded responses are pipeline-owned. Retry and hedging dispose failed or losing responses,
including a loser that completes after the winner; the handler retains an idempotent safety net
for custom strategies. A custom
OnRetrydisposal hook is unnecessary.OnRetryobserves the live response, disposal completes before the next attempt starts, and the selected response remains caller-owned. - Redirects remain transport-owned. Each Kevlar attempt begins with the original absolute URI (or its routed authority). Normal
HttpClientHandlerredirect policy runs inside that attempt. - Named-client state survives handler rotation. Service-provider
AddShieldregistrations and all standard registrations build or resolve one pipeline for that named client registration. Fixed-shield overloads already share their shield instance; request-selector overloads intentionally select a shield per request. Service-provider factories run once against the application provider, and circuit breakers, concurrency limiters, and endpoint caches are not multiplied whenHttpClientFactoryrotates handlers. - Configuration-backed state is replaced, not mutated. Only a valid configuration reload publishes a fresh complete pipeline. Handler rotation reuses the current snapshot, and requests already executing retain the snapshot they captured at send start.
- Standard hedging state is authority-local.
AddStandardHedgeShieldcreates one breaker and, when configured, one limiter per request authority or configured endpoint authority and preserves those instances across handler rotation until configuration reload replaces the pipeline. - Per-handler state remains explicit. When fresh state for every handler lifetime is intentional, register
ShieldDelegatingHandlerdirectly withAddHttpMessageHandlerand construct the shield inside that low-level handler factory. - Compose with other handlers normally. The Kevlar handler is a regular
DelegatingHandler; ordering relative to your own handlers follows the usualAddHttpMessageHandlerrules.
WhenTransient() is a normal handling clause — everything you chain after it (retry, breaker, fallback) reacts to that transient-fault definition. Add your own Or…/OrResult… calls to extend it. The builder it returns is immutable, so one stored WhenTransient() can be branched into several pipelines without the branches leaking terms into each other.