Skip to content

Resilience ​

The Resilience type is a sealed partial record in the NResilience namespace. It is immutable; derive a variant with the with expression.

Presets ​

The presets cover common scenarios:

PresetBehavior
Resilience.NonePassthrough. Executes one attempt with no bounds or budget, and Adaptive is false, which disables measurement. The executor returns the callback's own task. To derive a bound from this preset, enable that bound by name and set Adaptive to true.
Resilience.DefaultThree attempts, 30-second deadline, 10-second attempt timeout, Backoff.Default, and Classifier.Default.
Resilience.HttpA Default policy configured with Classifier.Http and Name = "http".

Properties ​

PropertyTypeDefaultDescription
Attemptsint3How many attempts to make. 1 means no retry; 3 means try, then retry twice. A count of calls, not a count of retries.
Restartsint3How many times a checkpointed stream may reconnect after failing part-way through. Read only by the RunAsync/TryRunAsync overloads that take a checkpoint. Each restart is a fresh attempt sequence with its own Attempts, so this bounds the total separately. See Checkpointed resume.
DeadlineTimeSpan30 sThe wall-clock budget for the entire call. Use Timeout.InfiniteTimeSpan to disable the bound.
AttemptTimeoutTimeSpan10 sThe maximum duration for a single attempt. The effective value is the minimum of this property and the remaining time on the deadline.
AttemptCeilingAttemptCeiling?AttemptCeiling.Above(3)A measured attempt ceiling. Set it to null to leave AttemptTimeout as the only per-attempt bound. The measured term can only lower the ceiling. No default is supplied when AttemptTimeout is Timeout.InfiniteTimeSpan or at or below AttemptCeiling.Floor, because there is no ceiling there to lower.
UseAmbientDeadlineboolfalseWhether the deadline is clamped by the one the current call inherited from its caller. When set, the effective deadline is the minimum of Deadline and AmbientDeadline.Remaining, resolved once per call.
UseAmbientCriticalityboolfalseWhether the policy reads how much the current call matters. When set, a Sheddable call is never hedged and is refused a retry once the Budget bucket is more than half spent; every other level is unaffected. Requires a Hedge, or more than one attempt and a Budget other than RetryBudget.None. See Criticality.
BackoffBackoffBackoff.DefaultThe delay between attempts.
ClassifierClassifierClassifier.DefaultThe logic used to classify outcomes.
BreakerBreaker?nullThe circuit breaker. A null value indicates no breaking is active.
HedgeHedge?nullHedging. A null value indicates no hedging. Requires Attempts greater than 1.
SaturationSaturation?nullLocal saturation awareness. When set, the policy stops feeding AttemptCeiling, Backoff.MeasuredBase and the Hedge threshold while this process's thread pool queues more than Multiple times its own normal delay. It only declines to record: nothing is refused and no bound moves. Requires at least one of those three features to be configured. See Local saturation.
BudgetRetryBudgetRetryBudget.AutomaticThe retry budget. RetryBudget.Automatic creates a budget private to the policy instance, or to each key when the policy is scoped. RetryBudget.None is no budget. Any other instance is shared wherever the instance is shared.
BeforeAttemptFunc<NextAttempt, Task>?nullA function that runs before every attempt, including the first.
OnEventAction<CallEvent>?nullThe telemetry listener. If null, no events are raised and no performance cost is incurred.
AdaptivebooltrueWhether the policy measures the dependency and bounds itself by what it measures. false suppresses every measured term the library would supply - such as AttemptCeiling - and leaves only the constants written here. It does not reach Breaker, which has its own switch. Setting it false alongside a configured AttemptCeiling, Hedge or Saturation results in an error.
Namestring?nullA name used in diagnostics and telemetry tags.
TimeTimeProviderTimeProvider.SystemThe clock used for timing. Use the system provider in production.

One property is computed rather than configured:

PropertyTypeDescription
MeasuredMeasuredValuesWhat the policy is currently measuring. See MeasuredValues.

MeasuredValues ​

policy.Measured is the one place a dashboard looks. Every property is a reading, never configuration - the configuration that produces it is AttemptCeiling, Backoff.MeasuredBase, Hedge and Saturation respectively. Each returns null when its feature is not configured, or when the estimate is still cold, and reading one validates the policy exactly as executing it does.

PropertyTypeDescription
AttemptCeilingTimeSpan?What AttemptCeiling currently measures the ceiling to be, before AttemptTimeout and the deadline clamp it. A value above AttemptTimeout means the clamp is what bounds the attempt.
BackoffBaseTimeSpan?The base delay the next transient retry would wait when Backoff.MeasuredBase is configured, after the Spread clamp.
HedgeThresholdTimeSpan?How long a call has to run before a hedge arms, after Hedge.MinimumDelay has floored it. This is the latency at which the library starts duplicating load. The gates that can still refuse a hedge - the breaker, the win rate, the concurrency ceiling, the remaining deadline - are asked when the threshold fires, so a reading is when a hedge would be considered rather than a promise that one starts.
QueueDelayTimeSpan?How long a work item currently waits for a thread in this process's pool, when Saturation is configured. A non-null reading is not the same as "saturated" - the comparison is against the process's own recent median, so what matters is how far the number has moved. SaturationDetected is what says the comparison fired.

The estimates are private to the policy instance. The HTTP handler derives one policy per host, so each host is measured independently. QueueDelay is the exception: there is one thread pool, so every policy in the process reports the same number for it.

Explaining a policy ​

Explain() returns what the policy will do, as text: which bound binds first, the worst-case timeline attempt by attempt, the load a call can add, what each measured term currently reads, and what the breaker and the classifier will do about a failure.

The worst-case wall clock of a call is a function of Attempts, Deadline, AttemptTimeout, AttemptCeiling, Backoff, the breaker's state and the budget's fill. Explain() computes it.

csharp
var api = Resilience.Http with { Deadline = TimeSpan.FromSeconds(value: 5), Name = "api" };

// At a REPL, or once at startup. Nothing here contacts a dependency or runs your callback.
Console.WriteLine(value: api.Explain());

// The allocation-free form, for a log that wants it on a stream rather than on the heap.
api.Explain(writer: Console.Out);

For Resilience.Http with { Deadline = TimeSpan.FromSeconds(5), Name = "api" }:

text
Policy "api" - 3 attempts, 5s deadline, 10s attempt timeout

  Bound first by:  the deadline. Attempt 1 is clamped to 5.00s, 50% of the 10s attempt timeout,
                   and attempts 2-3 never start.

  Worst case:      attempt 1  0.00s -> 5.00s  min(10s attempt timeout, 5.00s left)
                   deadline   5.00s           DeadlineExceededException
                   (a throttled attempt uses the 1s throttled base; Retry-After wins over both)

  Load:            up to 3.0x per call from retries. The automatic retry budget, which is
                   private to this policy, holds the sustained rate to 1.1x - 10% of successful
                   attempts, with a 3/s floor - and it is 0% spent.

  Measured now:    attempt ceiling   -  cold - 3x p95 over 5m, 20 samples minimum
                   backoff base      -  not configured
                   hedge threshold   -  not configured
                   pool queue delay  -  not configured

  Breaker:         not configured, so nothing stops a call reaching a dependency that is already
                   down.

  Classifier:      Http
                     exception HttpRequestException -> Transient
                     exception TimeoutException -> Transient
                     exception IOException -> Transient
                     exception SocketException -> Transient
                     result HttpResponseMessage -> (predicate)
                     any other exception -> Permanent
                     any other result -> Ok

Two lines carry most of the value:

  • Bound first by names the bound that ends the worst case, and what it costs. An open breaker or a spent retry budget comes before any arithmetic, because a call refused by either never reaches the timeline below it.
  • Measured now distinguishes configured from in effect. A measured term is invisible until it has samples, and a live reading is the only way to tell a cold estimate from a warm one.
csharp
var api = Resilience.Http with { AttemptTimeout = TimeSpan.FromSeconds(value: 30) };

// Configured: 30s. In effect: nothing yet - the ceiling is cold until it has samples, and
// until then the attempt gets the 30 seconds. The "Measured now" block reports both.
var configured = api.AttemptTimeout;
var inEffect = api.Measured.AttemptCeiling;

Explain() is safe to call on a policy that cannot be executed: an invalid policy is reported as such and the timeline is still laid out, because the timeline is usually what makes the problem obvious. Nothing in it runs the callback, contacts a dependency, or changes the policy.

The same text reaches three other places:

WhereWhat it shows
ResilienceConfigurationException.MessageThe header and the worst-case timeline, after the list of problems.
The health check payloadOne line per registered policy, under the key policy:<name>.
NRES004The same durations and the same clamp clause, computed at build time.

A call that does not ask for an explanation pays nothing for the method: no field on the record, no branch in the executor, and no allocation. Reading the measured terms validates the policy, exactly as Measured does.

Methods ​

The Resilience record provides the execution methods.

MethodReturn Type
RunAsync<T>(Func<CancellationToken, Task<T>>, CancellationToken)ValueTask<T>
RunAsync(Func<CancellationToken, Task>, CancellationToken)ValueTask
RunAsync<TState, T>(Func<TState, CancellationToken, Task<T>>, TState, CancellationToken)ValueTask<T>
RunAsync<TState>(Func<TState, CancellationToken, Task>, TState, CancellationToken)ValueTask
TryRunAsync<T>(…)ValueTask<CallResult<T>>
TryRunAsync(…)ValueTask<CallResult>
TryRunAsync<TState, T>(…)ValueTask<CallResult<T>>
TryRunAsync<TState>(…)ValueTask<CallResult>
RunAsync<T>(Func<CancellationToken, IAsyncEnumerable<T>>, CancellationToken)IAsyncEnumerable<T>
RunAsync<TState, T>(Func<TState, CancellationToken, IAsyncEnumerable<T>>, TState, CancellationToken)IAsyncEnumerable<T>
TryRunAsync<T>(Func<CancellationToken, IAsyncEnumerable<T>>, CancellationToken)ValueTask<CallResult<IAsyncEnumerable<T>>>
TryRunAsync<TState, T>(Func<TState, CancellationToken, IAsyncEnumerable<T>>, TState, CancellationToken)ValueTask<CallResult<IAsyncEnumerable<T>>>
RunAsync<TCheckpoint, T>(Func<TCheckpoint, CancellationToken, IAsyncEnumerable<T>>, TCheckpoint, Func<T, TCheckpoint>, CancellationToken)IAsyncEnumerable<T>
TryRunAsync<TCheckpoint, T>(Func<TCheckpoint, CancellationToken, IAsyncEnumerable<T>>, TCheckpoint, Func<T, TCheckpoint>, CancellationToken)ValueTask<CallResult<IAsyncEnumerable<T>>>
Explain()string
Explain(TextWriter)void
Validate()void
Validated()Resilience
WithListener(Action<CallEvent>)Resilience

The eight buffered execution overloads have counterparts that take ValueTask-returning callbacks, for Channel, PipeReader, Socket, Stream and anything else built on IValueTaskSource. These counterparts use the same names and argument order:

MethodReturn Type
RunAsync<T>(Func<CancellationToken, ValueTask<T>>, CancellationToken)ValueTask<T>
RunAsync(Func<CancellationToken, ValueTask>, CancellationToken)ValueTask
RunAsync<TState, T>(Func<TState, CancellationToken, ValueTask<T>>, TState, CancellationToken)ValueTask<T>
RunAsync<TState>(Func<TState, CancellationToken, ValueTask>, TState, CancellationToken)ValueTask
TryRunAsync<T>(…)ValueTask<CallResult<T>>
TryRunAsync(…)ValueTask<CallResult>
TryRunAsync<TState, T>(…)ValueTask<CallResult<T>>
TryRunAsync<TState>(…)ValueTask<CallResult>

These extension methods live in the NResilience namespace, so they need no using beyond the one for Resilience. A lambda returning a ValueTask binds to these methods. An async lambda binds to the Task overload, because C# considers extension methods only when no instance method applies.

csharp
// ReadAsync returns a ValueTask, so this binds to the ValueTask overload. 
// Buffered reads allocate nothing.
var buffered = await api.RunAsync(
    static (source, attempt) => source.ReadAsync(cancellationToken: attempt),
    state: feed,
    cancellationToken: cancellationToken);

// ReadNameAsync returns a Task, so this binds to the Task overload. Same name, same shape.
var name = await api.RunAsync(attempt => db.ReadNameAsync(id: id, cancellationToken: attempt), cancellationToken: cancellationToken);

To use the ValueTask path with an async lambda, provide an explicit return type: async ValueTask<int> (ct) => …. This is rarely necessary, because an async lambda allocates its own state machine regardless of return type. See where the allocations are for what the overloads save and why they are shaped this way.

The six streaming overloads take a cold source - a callback returning IAsyncEnumerable<T> - rather than a task, so a lambda binds to them by return type alone. Each attempt re-invokes the source, retrying until the first element is yielded, then hands the rest of the enumeration to the caller untouched. A policy with Hedge configured is refused by these overloads at the call. See streaming for the semantics.

The streaming TryRunAsync awaits to the first element and reports what a failed RunAsync would have thrown from the first MoveNextAsync. The value of a successful CallResult<IAsyncEnumerable<T>> is the started enumeration: it is enumerable once, and it implements IAsyncDisposable for a caller who reads IsSuccess and then decides not to consume it. A fault after the first element is not part of the result - it throws from MoveNextAsync, because the result was decided before that element existed.

The two checkpointed overloads take a start checkpoint and a checkpoint reader alongside the source, and restart the source from the last one accepted instead of stopping when a fault arrives after the first element - see checkpointed resume. CallResult<IAsyncEnumerable<T>>.Attempts is AttemptLog.Empty on a successful checkpointed TryRunAsync: the log on the throwing form's failure is one restart's own, and a checkpointed stream's history spans restarts no single AttemptLog was shaped to describe.

NOTE

A lambda that only throws needs an explicit return type here - TryRunAsync(Task<int> (ct) => throw new IOException()) - because a lambda with no return statement is ambiguous between the Task<T> and IAsyncEnumerable<T> overloads.

Execution behavior ​

RunAsync methods throw the original exception with its stack trace intact, or one of the exceptions defined by the library. TryRunAsync methods return a CallResult and always materialize the attempt log - including the streaming form, whose log covers the attempts up to the first element.

Cancellation tokens ​

Every method signature includes two different CancellationToken parameters:

  1. The callback token: Passed to the execution callback. Cancelled when the attempt hits its AttemptTimeout or when the caller's token is cancelled.
  2. The caller token: The trailing parameter. Cancels the whole operation, including all retries.

For more information, see the cancellation contract.

State and allocation ​

The TState overloads allow static callbacks, which avoid closure allocations. They behave the same as the closure-based forms.

Validation ​

Validate checks the policy configuration and throws ResilienceConfigurationException if it finds problems. Validation does not happen at construction; it runs when you call Validate explicitly, during eager DI registration, or lazily on a policy instance's first execution.

Validated() runs the same check and returns the policy, so a bad configuration throws where the policy is written rather than on the first call. That is the shape for a static readonly field, where a lazily-thrown configuration error would otherwise surface much later as a TypeInitializationException:

csharp
public static class Policies
{
    public static readonly Resilience Api = (Resilience.Http with { Deadline = Config.ApiDeadline }).Validated();
}

NOTE

The parentheses are required. C# does not allow member access directly on a with expression.

BreakerSettings, HttpResilienceOptions and GrpcResilienceOptions each have the same Validate() / Validated() pair, for the same reason.

Adding a listener ​

WithListener(listener) returns the policy with one more listener on OnEvent, added to whatever is already there:

csharp
var counted = Policies.Api.WithListener(e => Metrics.Record(e.Kind));

Assigning OnEvent in a with expression replaces it instead, which silently drops the telemetry and logging a container registration attached. WithListener is what WithTelemetry() and WithLogging() do to each other. Listeners run in the order they were added.

BeforeAttempt and Admit have no equivalent and are single slots by design: two pieces of setup are one hook that does both, and combining two admission guards needs a rule for which refusal wins that belongs to your system rather than to the library.

NextAttempt ​

The NextAttempt readonly struct is passed to BeforeAttempt, Admit and Backoff.Custom.

MemberDescription
NumberThe 1-based index of the attempt (1 for the first attempt).
PreviousVerdictThe classification of the previous attempt. Defaults to Verdict.Ok for the first attempt.
PreviousExceptionThe exception thrown by the previous attempt, if any.
RemainingThe time remaining on the deadline, or Timeout.InfiniteTimeSpan.
CancellationTokenThe caller's cancellation token.

AmbientDeadline ​

AmbientDeadline is a static class holding the deadline the current logical call inherited, plus the two helpers that put one on a wire. The executor reads it only for a policy whose UseAmbientDeadline is set.

MemberDescription
HeaderThe default header name: "X-Deadline-Ms".
RemainingHow long the inbound deadline has left, or null when the call inherited none. TimeSpan.Zero when it inherited one that has expired.
Begin(remaining, time = null)Publishes an inbound deadline for the current logical call. Returns an AmbientDeadline.Scope that restores the previous value when disposed. Timeout.InfiniteTimeSpan clears the deadline for the scope rather than publishing an unbounded one.
TryParse(value, out remaining)Reads a header value: whole milliseconds as a positive integer. Anything else - empty, zero, negative, unit-suffixed, or above int.MaxValue milliseconds - is no deadline, and the failure is silent.
Format(remaining)Writes a header value: whole milliseconds, rounded down, never below 1. null when there is nothing to say.

The effective deadline is min(Deadline, Remaining), resolved once when the call starts. See deadline propagation for both halves, and the cancellation contract for what the ambient read costs.

AmbientCriticality ​

AmbientCriticality is a static class holding how much the current logical call matters, plus the two helpers that put a level on a wire. The executor reads it only for a policy whose UseAmbientCriticality is set.

MemberDescription
HeaderThe default header name: "X-Criticality".
CurrentThe level this call is running at. Criticality.Critical when nothing published one.
Begin(criticality)Publishes a level for the current logical call. Returns an AmbientCriticality.Scope that restores the previous value when disposed. An undeclared level throws ArgumentOutOfRangeException.
TryParse(value, out criticality)Reads a header value: one of the four level names, matched without regard to case. "CriticalPlus" parses and clamps to Critical, because a caller cannot escalate itself. Anything else is no level, the failure is silent, and the out value is Critical.
Format(criticality)Writes a header value: the level's name. An undeclared level throws ArgumentOutOfRangeException.

Criticality is a byte-backed enum ordered from least to most important: Sheddable, SheddablePlus, Critical, CriticalPlus. See Criticality for both halves and for what the executor does with a level.

NestedRetry ​

NestedRetry is a static class holding whether the caller of the current logical call is already retrying, plus the header and marker that carry that fact across a hop. It is the flag counterpart to AmbientDeadline: a bool rather than a value with state, because unlike a deadline it does not decay.

MemberDescription
HeaderThe header a retrying client stamps on every request it can retry: "X-NResilience-Retrying".
MarkerThe only value that header ever carries: "1".
IsCallerRetryingWhether the caller said it was already retrying. false when nobody published a flag.
Begin(callerRetrying)Publishes the flag for the current logical call. Returns a NestedRetry.Scope that restores the previous value when disposed.
IsMarker(value)Whether a header value is Marker. The check is exact, because a value this library did not write is not evidence of anything.

See nested retries for both halves, and UseResilienceNestedRetry for the inbound middleware.

PolicyScope<TKey> ​

PolicyScope<TKey> is a sealed class holding one policy per key, each with its own breaker, retry budget, and hedging latency estimate. TKey must be non-nullable. Every member is thread-safe. Hold one for the life of the process - see keyed policy scope.

MemberDescription
PolicyScope(template, shape = null, maximumKeys = 1024, comparer = null)Creates a scope. template is validated eagerly. shape derives a key's policy on first sight. maximumKeys must be at least 1. comparer defaults to EqualityComparer<TKey>.Default.
For(key)The policy for one key, derived on first sight and cached.
Breakers()A snapshot of the breakers, by key. Empty when the template carries no breaker.
Budgets()A snapshot of the retry budgets, by key.
TemplateThe policy every key starts from, as handed in.
MaximumKeysHow many keys the scope keeps.
CountHow many keys it currently holds. Approximate under concurrency, and briefly above MaximumKeys while a sweep catches up.

A Breaker on the template is a prototype: each key gets its own breaker with those settings, and the template's instance is never executed against. A Budget of RetryBudget.Automatic becomes one budget per key; any other value, including RetryBudget.None and RetryBudget.Shared(name), is left alone.

Equality ​

Two policies are equal when all their properties are equal. Breaker and RetryBudget are compared by reference because they are live state objects rather than configuration. ToString returns the policy configuration.

Released under the MIT License.