Skip to content

Migrating from Polly ​

NResilience approaches resilience differently from Polly. This guide translates the core concepts and explains the behavioral differences you will meet during migration.

The Polly snippets below are illustrative; all NResilience snippets are compiled and verified.

Concept translation ​

PollyNResilience
ResiliencePipelineResilience (a value, not a built pipeline)
ResiliencePipelineBuilder ... Build()with expression on a policy
AddRetryAttempts, Backoff
AddTimeout (per attempt)AttemptTimeout
AddTimeout (outer)Deadline
AddCircuitBreakerBreaker (an object you maintain)
AddBulkheadLimit.Concurrency (bulkhead pattern), or Limit.Adaptive to discover the number instead of picking it
AddFallbackif logic on a CallResult<T>
AddHedgingHedge, against a live latency quantile rather than a fixed delay
ShouldHandle predicatesOne Classifier used by all strategies
ResilienceContext, ResiliencePropertiesTState execution overloads
ResiliencePipelineProvider<string>IResiliencePolicies
AddResiliencePipeline("name", ...)services.AddResilience("name", ...)
AddStandardResilienceHandler().AddResilience()
A hand-rolled retrying gRPC Interceptor.AddGrpcResilience()
ShouldHandle over RpcException.StatusCodeGrpcResilience.Classifier, overridable one line at a time
A CallOptions.Deadline you compute per callDeadline and AttemptTimeout on the policy; the interceptor puts the attempt's ceiling on the wire as grpc-timeout
A server stream you re-open by hand when it fails to startAddGrpcResilience(), which retries a server stream until the first message and never after it
OnRetry, OnTimeout, OnOpened, etc.One OnEvent listener
resilience.polly.* metricsnresilience.* metrics
Simmy chaos strategies (AddChaosFault, AddChaosLatency, AddChaosOutcome)Chaos in NResilience.Testing, which wraps the callback rather than adding a strategy

Implement a retry, timeout, and breaker ​

Before (Polly) ​

csharp
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        MaxRetryAttempts = 2,                       // 2 retries, so 3 attempts
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<HttpRequestException>()
            .HandleResult(r => (int)r.StatusCode >= 500),
    })
    .AddTimeout(TimeSpan.FromSeconds(3))
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>())
    .Build();

var response = await pipeline.ExecuteAsync(ct => Send(ct), cancellationToken);

After (NResilience) ​

csharp
// One value. No pipeline, no builder, no ordering to get right - and the breaker samples
// attempts whichever way you read it.
var api = Resilience.Http with
{
    Attempts = 3, // total, including the first
    AttemptTimeout = TimeSpan.FromSeconds(value: 3), // per attempt
    Deadline = TimeSpan.FromSeconds(value: 10), // the whole call
    Breaker = Breaker.Of(name: "api"),
};

In this migration, Attempts is the total number of calls, so MaxRetryAttempts = 2 becomes Attempts = 3. The status-code and exception predicates become a classifier, already configured for HTTP in the Resilience.Http preset.

Implement a fallback ​

Before (Polly) ​

csharp
.AddFallback(new FallbackStrategyOptions<string>
{
    FallbackAction = _ => Outcome.FromResultAsValueTask("cached"),
})

After (NResilience) ​

csharp
var result = await api.TryRunAsync(attempt => calls.NextAsync(cancellationToken: attempt), cancellationToken: cancellationToken);
var value = result.TryGetValue(value: out var fetched) ? fetched : "cached";

A fallback is an if check on the CallResult, and doing it at the call site makes it obvious whether the value came from the dependency or the fallback.

Register the policy ​

Before (Polly) ​

csharp
services.AddHttpClient<Client>().AddStandardResilienceHandler();

After (NResilience) ​

csharp
services.AddHttpClient<Client>().AddResilience();

Configure predicates ​

Before (Polly) ​

csharp
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
    .HandleResult(r => r.StatusCode == HttpStatusCode.Conflict),

After (NResilience) ​

csharp
// Classifier.Http already knows that a 429 is throttling, a 5xx or 408 is transient and a
// 404 is an answer. Adding a status of your own is one rule, and retry, the breaker and
// the budget all read it.
var api = Resilience.Http with
{
    Backoff = Backoff.None,
    Classifier = Classifier.Http.OnResult<HttpResponseMessage>(r =>
        r.StatusCode == HttpStatusCode.Conflict ? Verdict.Transient : Classifier.Http.ClassifyResult(value: r)),
};

Implement bulkhead isolation ​

Before (Polly) ​

csharp
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddBulkhead(handledByEntityKey => 10)  // max 10 concurrent calls
    .Build();

After (NResilience) ​

csharp
// For HTTP clients via dependency injection
services.AddHttpClient<PaymentClient>()
    .AddResilience()
    .AddRateLimit(options => options.Concurrency = 10);

// For any other callback
await using var limiter = Limit.Concurrency(permits: 10);

var result = await policy.RunAsync(async ct =>
{
    using var lease = await limiter.AcquireOrThrowAsync(cancellationToken: ct);
    return await dependency.CallAsync(cancellationToken: ct);
}, cancellationToken: cancellationToken);

The bulkhead pattern keeps one slow dependency from monopolizing your thread pool. Limit.Concurrency does this more efficiently than Polly's thread pool partitioning:

  • Zero allocation when unused
  • Each attempt acquires its own permit (retries don't reuse slots)
  • Refusals are classified Verdict.Throttled(SelfImposed: true): retried on the long backoff curve, never opening the breaker
  • For HTTP, scoped per host by default (like circuit breakers)

The full guide with examples is Resource isolation with bulkheads.

Handle exceptions and state ​

Before (Polly) ​

ExecuteAsync wraps failures and passes state through a rented ResilienceContext.

After (NResilience) ​

csharp
// The original exception is rethrown unchanged, with its stack intact, so existing catch
// blocks keep working. The history rides along on Exception.Data.
try
{
    await api.RunAsync(attempt => calls.NextAsync(cancellationToken: attempt), cancellationToken: cancellationToken);
}
catch (HttpRequestException e)
{
    var attempts = AttemptLog.Of(exception: e);
    Console.WriteLine(value: attempts); // 3 attempts over 1.4ms: Transient HttpRequestException (0.5ms), ...
}

The original exception comes back unchanged, so existing catch blocks keep working. Instead of a context object, use the TState execution overloads to pass your own state to the callback - which also lets the lambda be static.

Behavioral differences ​

Six behavioral differences to know about when migrating:

  • Limited HTTP retries: Classifier.Http treats all 4xx statuses as answers, except 408 and 429. The HTTP handler does not retry POST or PATCH unless you mark the request repeatable.
  • Unrecognized exceptions: Classifier.Default treats unknown exception types as Permanent. For a broad handler, use Classifier.RetryEverything.
  • Active retry budget: By default, retries are capped at 10% of successful traffic per policy. A load test against a dead dependency returns StopReason.BudgetExhausted. Disable with RetryBudget.None; see the Retry budget guide.
  • Refusal pause: An open circuit breaker pauses 100 milliseconds before reporting a failure, so the breaker cannot become a load generator. See Guarded rejection.
  • Measured bounds you did not write: the attempt ceiling and two of the breaker's three trip conditions are measured from the dependency's own latency and error rate, and all three are on by default. A migrated configuration therefore gets bounds Polly has no equivalent of - each of which can only tighten a bound you did write, and none of which is armed until it has a baseline. See attempt timeouts and trip conditions.
  • One attempt count: Polly's hedging has its own MaxHedgedAttempts alongside retry's MaxRetryAttempts, and the product is the real ceiling on load. Here Attempts is the total number of calls that reach the dependency whatever shape they run in, and Hedge.MaximumConcurrent bounds only how many overlap. Migrating MaxRetryAttempts = 2 plus MaxHedgedAttempts = 2 means deciding what the total should be, not multiplying. There is also no fixed-delay hedge to migrate: see Hedging.

Run NResilience and Polly together ​

Both libraries can run in the same process. The metric names, tag names, and event names do not overlap with Polly's vocabulary, so they are distinguishable in dashboards. Migrate clients one at a time.

Released under the MIT License.