9 min readPower features

Bending the library

Explore the extension points of NResilience: custom admission, custom backoff, per-attempt hooks, and telemetry listeners.

extensibilityhooks

TL;DR: NResilience uses a flat executor to keep performance high and allocations low. To avoid the complexity of "strategy" objects, it provides four targeted extension points - Admit, BeforeAttempt, Backoff.Custom, and OnEvent - that let you inject custom logic into the resilience loop without sacrificing the zero-allocation path.

The extension surface

Most resilience needs are covered by the built-in presets. You want a 30 s deadline, exponential backoff, and a circuit breaker - you use Resilience.Http and you're done. But real-world dependencies occasionally have requirements that don't fit a standard curve or a simple "is this a 503?" classifier.

Instead of opening up the executor to arbitrary "strategy" plugins - which would introduce virtual calls and layered overhead - NResilience exposes specific hooks. These are designed to be the minimum viable surface needed to handle 99% of custom cases while keeping the core loop flat.

The surface consists of:

  • Admit: A guard that decides if an attempt should even start.
  • BeforeAttempt: A setup hook for preparing the call.
  • Backoff.Custom: A way to calculate your own retry delays.
  • OnEvent: A listener for every stage of a call, from attempts to the terminal outcome.

Admit: The classified guard

The Admit hook is a Func<NextAttempt, Task<Verdict>>. It runs once per attempt, inside the same classified region as the call itself.

The key distinction here is that Admit returns a Verdict. If you return Verdict.Limited (or its alias Verdict.Refused), the executor treats the attempt as a self-imposed refusal: logged, exempt from the breaker and the retry budget, and reported in the terminal CallEvent. That exemption is deliberate - your own guard refusing a call is not evidence that the dependency is failing.

Use Admit when you have a guard whose outcome is naturally a decision rather than an exception - a distributed lock, a custom quota, anything that should refuse a call before it reaches the dependency. If throwing is more natural, the same classification works through a custom guard exception and a Classifier rule; both reach the identical code path.

There is a small performance trade-off: configuring Admit selects a separate execution path in the library to avoid paying an await tax on every single call. If you don't use it, you pay nothing. If you do, you pay one extra hoisted awaiter field in the state machine - a negligible cost for the flexibility it provides.

One thing to know if your guard answers from memory. The hook returns Task<Verdict>, not ValueTask<Verdict>, for the reason above - so a synchronous guard has to wrap its answer in a task on every attempt. Hand back Verdict.OkTask instead of Task.FromResult(Verdict.Ok):

var policy = Resilience.Default with
{
    Admit = _ => gate.Wait(0)
        ? Verdict.OkTask
        : Task.FromResult(Verdict.Limited(TimeSpan.FromMilliseconds(50))),
};

Verdict.TransientTask and Verdict.PermanentTask are the same thing for the other two constant verdicts. There is no cached task for Throttled, Limited or Refused - each takes a pushback, so there is no single value to cache, and a guard that refuses is on the slow path anyway, where the retry it causes costs far more than the task wrapping its answer.

The clearest case for it is priority-aware shedding, and the library deliberately stops one step short of doing it for you. AmbientCriticality.Current tells you how much the current call matters - a value that traveled with the request rather than one you guessed - and what Sheddable should cost your service is the part only you can answer:

var policy = Resilience.Http with
{
    Admit = _ => AmbientCriticality.Current < Criticality.Critical && Overloaded()
        ? Task.FromResult(Verdict.Refused(TimeSpan.FromSeconds(1)))
        : Verdict.OkTask,
};

The library holds back the two things Admit cannot reach - it never hedges sheddable work, and it will not spend a depleted retry budget on it - and leaves shedding to the hook. See Deadlines that travel for how the level gets there.

Admit is one slot: setting it replaces whatever was there. That is deliberate. Combining two guards needs a rule for which refusal wins, and that is a decision about your system rather than one the library should make for you. Two guards are one hook that checks both.

Backoff.Custom: Your own pacing

Standard exponential backoff is the right answer for most things, but sometimes you need a specific pacing logic - perhaps based on the specific exception thrown or a custom window of time.

Backoff.Custom takes a delegate that receives the NextAttempt context and returns a TimeSpan. This is where you implement your own pacing math.

using NResilience;

// Custom backoff that uses a linear increase for the first 3 attempts,
// then switches to a hard constant delay.
var customBackoff = Backoff.Custom(attempt => 
{
    if (attempt.Number <= 3)
    {
        return TimeSpan.FromMilliseconds(100 * attempt.Number);
    }
    
    return TimeSpan.FromSeconds(2);
});

var policy = Resilience.Default with 
{ 
    Backoff = customBackoff,
    Attempts = 5 
};

One detail: server pushback always wins. If the previous verdict carried a Retry-After value, NResilience honors it verbatim, capping it by MaximumDelay. Your custom logic is only invoked when the server is silent about when you should return.

And a custom curve does not have to start from nothing. Compute is public, so a built-in curve can be the baseline and your delegate adjusts it - which is how you add a term to exponential backoff without reimplementing it:

var baseline = Backoff.Exponential();
var curve = Backoff.Custom(next => baseline.Compute(next) + TimeSpan.FromSeconds(next.Number - 1));

BeforeAttempt: The setup hook

BeforeAttempt is a Func<NextAttempt, Task>. Unlike Admit, it doesn't return a verdict - it just runs. It is the place to build a fresh request, refresh an auth token, or reset a stateful object before the attempt begins.

Crucially, BeforeAttempt runs outside the classification region. If it throws, the exception propagates out of the call immediately. It is not retried, not logged to the attempt log, and does not raise a CallEvent. This is intentional: if your setup fails, the resilience loop cannot recover it. Use it for setup that always has to run; if your guard needs retry, backoff, or telemetry, it belongs in the callback or in Admit.

using NResilience;

var policy = Resilience.Default with
{
    BeforeAttempt = next => TokenProvider.EnsureFreshTokenAsync(next.CancellationToken)
};

// Usage: The executor runs BeforeAttempt, then the callback.
await policy.RunAsync(
    attempt => httpClient.GetAsync("https://api.example.com/data", attempt),
    cancellationToken);

If you need a guard that can be retried or reported as a failure, use Admit. If you need to ensure a prerequisite is met before the clock starts ticking on the attempt, use BeforeAttempt.

OnEvent: Telemetry without the tax

NResilience follows a "pay-for-play" telemetry model. If you don't configure a listener, the executor raises nothing and pays nothing. No events are created, and no delegates are called.

OnEvent is an Action<CallEvent>. It fires for every stage of a call: each attempt as it finishes, each retry decision, breaker transitions, and exactly one terminal event at the end. The terminal CallEvent contains the final verdict, the total number of attempts, and the cumulative delay. Every event is the same struct, so a listener can switch on Kind rather than subscribe to a family of differently-shaped types.

Because the listener is synchronous and runs on the executor's thread, you should never do I/O inside it. Log it, increment a counter, or enqueue it to a background channel. An exception thrown by the listener is swallowed; telemetry should never be able to crash the operation it is observing.

The HTTP seam

While the hooks above live on the Resilience policy, the best way to extend the library's behavior specifically for HTTP is through a DelegatingHandler.

By placing a handler in the HttpClient pipeline, you can modify requests or responses before they even hit the NResilience executor. This is the correct place for things like adding custom headers, tracing IDs, or implementing custom response-shaping that should happen before the classifier sees the result.

The Hard No: Why no "Strategy" objects?

You might wonder why NResilience doesn't allow you to implement a IResilienceStrategy interface and plug it into the loop. This is a deliberate design choice.

Layered strategy pipelines (the "onion" model) are flexible, but they are slow. Each layer adds a virtual call and a new frame to the state machine. In a high-throughput system, this adds up to significant CPU overhead and increased allocations.

NResilience replaces the pipeline with a flat loop. The executor knows exactly how to handle retries, deadlines, and breakers. By using targeted hooks like Admit and BeforeAttempt, you get the extension points you actually need without introducing the architectural bloat of a general-purpose strategy framework.

The library optimizes for the common case - the fast, allocation-free call - and provides a narrow, well-defined escape hatch for the edges.

Conclusion

Bending the library is about using the right tool for the right intent. Use Admit for guards that should trip the breaker, BeforeAttempt for required setup, Backoff.Custom for non-standard pacing, and OnEvent for lean telemetry. By keeping the extension surface small, NResilience keeps the execution path flat and the performance predictable.

The OnEvent listener is the key to understanding what the executor is doing. One event per call explains the telemetry model and how the single terminal CallEvent gives you a clean view of your system's health.