Wiaoj.Resilience
0.1.0-alpha.7
dotnet add package Wiaoj.Resilience --version 0.1.0-alpha.7
NuGet\Install-Package Wiaoj.Resilience -Version 0.1.0-alpha.7
<PackageReference Include="Wiaoj.Resilience" Version="0.1.0-alpha.7" />
<PackageVersion Include="Wiaoj.Resilience" Version="0.1.0-alpha.7" />
<PackageReference Include="Wiaoj.Resilience" />
paket add Wiaoj.Resilience --version 0.1.0-alpha.7
#r "nuget: Wiaoj.Resilience, 0.1.0-alpha.7"
#:package Wiaoj.Resilience@0.1.0-alpha.7
#addin nuget:?package=Wiaoj.Resilience&version=0.1.0-alpha.7&prerelease
#tool nuget:?package=Wiaoj.Resilience&version=0.1.0-alpha.7&prerelease
Wiaoj.Resilience
Distributed circuit breaker and timeout primitives for the Wiaoj library family.
Circuit state is held in Wiaoj.DistributedCounter, so a circuit tripped by one node is observed by every node sharing the same storage backend. The package ships the breaker algorithms, policy registration builders, execution helpers with fallback degradation, and OpenTelemetry metrics and tracing.
Installation
dotnet add package Wiaoj.Resilience
Circuit Breaker Strategies
1. ConsecutiveFailuresCircuitBreaker (Distributed / Storage-Backed)
- Trips once
FailureThresholdconsecutive failures are recorded; a success resets the failure counter. - Half-open recovery admits exactly one trial probe, claimed atomically via
IDistributedCounter.TryIncrementAsyncso that only one caller across the cluster tests the target.
2. SamplingWindowCircuitBreaker (Distributed / Storage-Backed)
- Trips on the failure rate over a rolling window, evaluated only once
MinimumThroughputrequests have been observed within that window — a handful of failures during a quiet period will not trip it. - Half-open recovery admits up to
PermittedNumberOfCallsInHalfOpenStateconcurrent probes.
3. CompositeCircuitBreaker
- Evaluates an ordered sequence of breaker tiers. Execution is permitted only if every tier allows it; the first denying tier short-circuits the rest and its
RetryAfteris surfaced. - Useful for pairing a fast consecutive-failure trip with a slower percentage-based trip over a longer window.
All three implement ICircuitBreaker, which is transport-agnostic: the key identifies whatever you are protecting — an HTTP host, a tenant, a queue, a database shard.
The Three States
| State | Meaning |
|---|---|
Closed |
Operational. Requests proceed normally. |
Open |
Tripped. Requests are fast-failed until BreakDuration elapses. |
HalfOpen |
The break elapsed. A bounded number of trial probes are admitted to test recovery; their outcome closes or re-opens the circuit. |
Dependency Injection Setup
using Wiaoj.DistributedCounter;
using Wiaoj.Resilience;
var builder = WebApplication.CreateBuilder(args);
// 1. Configure the underlying counter backend that holds circuit state.
builder.Services.AddDistributedCounter(dc => dc.UseInMemory());
// 2. Configure resilience policies.
builder.Services.AddWiaojResilience(resilience => {
// Named policy: trip after 5 consecutive failures.
resilience.AddConsecutiveBreaker("payments", options => {
options.FailureThreshold = 5;
options.BreakDuration = TimeSpan.FromMinutes(1);
});
// Named policy: trip when 50% of at least 20 requests fail within 30s.
resilience.AddSamplingBreaker("search", options => {
options.FailureRateThreshold = 0.5;
options.MinimumThroughput = 20;
options.SamplingWindow = TimeSpan.FromSeconds(30);
options.BreakDuration = TimeSpan.FromMinutes(1);
options.PermittedNumberOfCallsInHalfOpenState = 3;
});
// Multi-tier: both tiers must allow execution.
resilience.AddCompositeBreaker("gateway", "payments", "search");
// Strongly-typed policy, keyed by the marker type's name.
resilience.AddConsecutiveBreaker<ShippingPolicy>(options => {
options.FailureThreshold = 3;
});
// Default fallback policy.
resilience.UseDefaultConsecutiveBreaker(options => {
options.FailureThreshold = 5;
options.BreakDuration = TimeSpan.FromSeconds(30);
});
// Timeout policies.
resilience.AddFixedTimeout("payments", TimeSpan.FromSeconds(3));
resilience.UseDefaultFixedTimeout(TimeSpan.FromSeconds(10));
});
Executing Through a Circuit
Resolve a named policy through ICircuitBreakerFactory, or inject ICircuitBreaker<TPolicy> for a typed one.
public sealed class PaymentClient(ICircuitBreakerFactory factory, HttpClient http) {
private readonly ICircuitBreaker _breaker = factory.Create("payments");
public ValueTask<Receipt> ChargeAsync(Order order, CancellationToken ct) {
// Records success or failure automatically.
// Throws CircuitBreakerOpenException (carrying Key and RetryAfter) when blocked.
return this._breaker.ExecuteAsync(
key: "payments-gateway",
operation: token => this.PostChargeAsync(order, token),
cancellationToken: ct);
}
}
Graceful Degradation
ExecuteWithFallbackAsync swallows the failure — including the open-circuit rejection — and returns a substitute instead, either a static value or a factory that receives the triggering exception. Caller cancellation is always rethrown rather than falling back.
IReadOnlyList<Product> results = await breaker.ExecuteWithFallbackAsync(
key: "search-cluster",
operation: token => this._search.QueryAsync(term, token),
fallbackFactory: (exception, token) => this._cache.LastKnownGoodAsync(term, token),
cancellationToken: ct);
Manual Outcome Reporting
When the operation is not a single delegate, drive the breaker directly. Keep only the operation inside the try, and record its outcome outside it:
CircuitExecutionDecision decision = await breaker.TryAcquireAsync(key, ct);
if(!decision.IsAllowed) {
return TooManyRequests(retryAfter: decision.RetryAfter);
}
try {
await DispatchAsync(ct);
}
catch(Exception) {
await breaker.OnFailureAsync(key, ct); // or use a ResilientCircuitBreaker, which never throws here
throw;
}
await breaker.OnSuccessAsync(key, ct);
Do not record success inside the
try. IfOnSuccessAsyncthrows (because the store is down), thecatchrecords a failure and rethrows, and the caller receives an exception for an operation that already completed. A caller that retries on exception then runs the operation twice.ExecuteAsyncis built this way.
When the Circuit's Store Is Unavailable
The built-in breakers keep their state in Wiaoj.DistributedCounter. When that store is unreachable, for example when Redis is down, the defaults behave as follows.
ExecuteAsyncnever runs an operation twice and never misreports one. Once the operation has run, the caller receives its result or its own exception. A failure to record the outcome is added as acircuit_breaker.record_failedevent on the span, and the span keeps the operation's outcome.- Acquiring throws. The operation has not run yet at that point, so nothing is repeated. However, every protected call is refused.
Refusing every call turns a store outage into an outage of everything the breakers protect. To let calls through instead, opt in to fail-open:
services.AddWiaojResilience(resilience => resilience
.AddConsecutiveBreaker("payments", o => o.FailureThreshold = 5)
.FailOpenOnStorageFailure());
Every breaker the factory hands out is then wrapped in ResilientCircuitBreaker: named, typed, default, and each child of a composite. This is the counterpart of ResilientRateLimiter.
| Member | When the store throws |
|---|---|
TryAcquireAsync |
Allows the call (Closed), and logs event 2006 |
GetStateAsync |
Reports Closed (event 2007), so routing does not drop a candidate whose state could not be read. With o.StateOnStorageFailure = StorageFailureState.Throw it rethrows instead, for a health page that must show "unknown", not "healthy". |
OnSuccessAsync / OnFailureAsync |
Logs event 2008 and returns, since this is bookkeeping about a call that already happened |
Some exceptions are never absorbed:
- Cancellation the caller requested always propagates.
- An
OperationCanceledExceptionthe caller did not request, such as the store client's own timeout, is treated as a store failure. - An
ArgumentException(a caller bug such as an invalid key) propagates.
You can also wrap a breaker yourself: new ResilientCircuitBreaker(inner, options, logger).
Reading State Without Acquiring
TryAcquireAsync is not a way to look at a circuit: in the half-open state it hands out the probe that decides whether the target has recovered. GetStateAsync makes no state transition and consumes no probe, which is what pre-emptive routing needs.
// Prefer a provider whose circuit is closed.
foreach(GatewayDescriptor candidate in candidates) {
if(await breaker.GetStateAsync(candidate.Key, ct) is CircuitState.Closed) {
return candidate;
}
}
The result is advisory and may be stale — state lives in a distributed counter and can change between the read and a subsequent acquire. A Closed result is not a guarantee that the next TryAcquireAsync will be permitted, so callers must still handle a denial at acquire time.
Timeouts
ITimeoutStrategy bounds an operation within a deadline, throwing TimeoutException when it is exceeded while leaving caller cancellation distinguishable as OperationCanceledException.
public sealed class ReportService(ITimeoutStrategy<ReportPolicy> timeout) {
public ValueTask<Report> BuildAsync(CancellationToken ct) {
return timeout.ExecuteAsync("report-build", token => this._builder.RunAsync(token), ct);
}
}
Named strategies are resolved through ITimeoutStrategyFactory, and ExecuteWithFallbackAsync overloads mirror the circuit breaker ones.
Configuration Reference
CircuitBreakerOptions (consecutive failures)
| Property | Default | Description |
|---|---|---|
FailureThreshold |
5 |
Consecutive failures required to trip the circuit. |
BreakDuration |
1 minute |
How long the circuit stays open before half-open probing. |
KeyPrefix |
wiaoj:resilience:cb: |
Storage key prefix for isolation. |
SamplingWindowCircuitBreakerOptions (failure rate)
| Property | Default | Description |
|---|---|---|
FailureRateThreshold |
0.5 |
Failure ratio (0.0–1.0) required to trip. |
MinimumThroughput |
10 |
Minimum requests in the window before the rate is evaluated. |
SamplingWindow |
30 seconds |
Rolling window across which the rate is calculated. |
BreakDuration |
1 minute |
How long the circuit stays open before half-open probing. |
PermittedNumberOfCallsInHalfOpenState |
5 |
Concurrent trial probes admitted during recovery. |
KeyPrefix |
wiaoj:resilience:cb: |
Storage key prefix for isolation. |
Both option types are validated on registration and implement IDeepCloneable<T> and IMergeable<T>.
Observability
Metrics are emitted on the Wiaoj.Resilience meter and spans on the Wiaoj.Resilience activity source.
| Instrument | Kind | Description |
|---|---|---|
circuit_breaker.decisions |
Counter | Acquire decisions, tagged by outcome and state. |
circuit_breaker.trips |
Counter | Transitions into the open state, tagged by reason. |
circuit_breaker.successes |
Counter | Recorded successes, flagging recoveries. |
circuit_breaker.failures |
Counter | Recorded failures. |
circuit_breaker.state |
Observable gauge | Current state per key. |
Spans are emitted on the Wiaoj.Resilience activity source for the delegate-wrapper execution model:
| Span | Emitted by | Tags |
|---|---|---|
circuit_breaker.execute |
ExecuteAsync / ExecuteWithFallbackAsync |
resilience.key, resilience.circuit_state, resilience.outcome, resilience.probe (half-open only), resilience.retry_after_ms (denied only) |
timeout.execute |
ITimeoutStrategy.ExecuteAsync |
resilience.key, resilience.timeout_ms, resilience.outcome |
resilience.outcome is one of success, failure, denied, timeout or cancelled; the span carries an Error status (and the recorded exception) for the first four. The zero-allocation TryAcquireAsync path emits metrics but no span, and StartActivity returns null when nothing is subscribed, so tracing costs nothing until a listener is attached.
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics.AddMeter("Wiaoj.Resilience"))
.WithTracing(tracing => tracing.AddSource("Wiaoj.Resilience"));
License
MIT
| 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
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.11)
- Wiaoj.DistributedCounter (>= 0.1.0-alpha.7)
- Wiaoj.DistributedCounter.Abstractions (>= 0.1.0-alpha.7)
- Wiaoj.Preconditions (>= 0.1.0-alpha.7)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0-alpha.7 | 36 | 9/18/2026 |
| 0.1.0-alpha.6 | 35 | 9/16/2026 |
| 0.1.0-alpha.5 | 39 | 9/16/2026 |
| 0.1.0-alpha.4 | 41 | 9/16/2026 |
| 0.1.0-alpha.3 | 37 | 9/15/2026 |
| 0.1.0-alpha.2 | 73 | 9/15/2026 |
| 0.1.0-alpha.1 | 46 | 9/14/2026 |
| 0.0.1-alpha.112-preview | 45 | 9/13/2026 |
| 0.0.1-alpha.111-preview | 46 | 9/13/2026 |
| 0.0.1-alpha.110-preview | 54 | 9/12/2026 |
| 0.0.1-alpha.109-preview | 50 | 9/11/2026 |
| 0.0.1-alpha.108-preview | 69 | 9/8/2026 |
| 0.0.1-alpha.107-preview | 68 | 9/8/2026 |
| 0.0.1-alpha.106-preview | 64 | 9/8/2026 |
| 0.0.1-alpha.105-preview | 65 | 9/8/2026 |
| 0.0.1-alpha.104-preview | 69 | 9/7/2026 |
| 0.0.1-alpha.103-preview | 56 | 9/7/2026 |
| 0.0.1-alpha.102-preview | 67 | 9/6/2026 |
| 0.0.1-alpha.101-preview | 92 | 9/6/2026 |
| 0.0.1-alpha.100-preview | 57 | 9/6/2026 |