Migrating from Polly
NResilience provides a different approach to resilience than Polly. This guide provides a translation table for core concepts and explains the behavioral differences you will encounter during migration.
The Polly snippets below are illustrative. All NResilience snippets are compiled and verified.
Concept translation
| Polly | NResilience |
|---|---|
ResiliencePipeline | Resilience (a value, not a built pipeline) |
ResiliencePipelineBuilder ... Build() | with expression on a policy |
AddRetry | Attempts, Backoff |
AddTimeout (per attempt) | AttemptTimeout |
AddTimeout (outer) | Deadline |
AddCircuitBreaker | Breaker (an object you maintain) |
AddBulkhead | Limit.Concurrency (bulkhead pattern) |
AddFallback | if logic on a CallResult<T> |
AddHedging | Not implemented. See the FAQ |
ShouldHandle predicates | One Classifier used by all strategies |
ResilienceContext, ResilienceProperties | TState execution overloads |
ResiliencePipelineProvider<string> | IResiliencePolicies |
AddResiliencePipeline("name", ...) | services.AddResilience("name", ...) |
AddStandardResilienceHandler() | .AddResilience() |
OnRetry, OnTimeout, OnOpened, etc. | One OnEvent listener |
resilience.polly.* metrics | nresilience.* metrics |
Implement a retry, timeout, and breaker
Before (Polly)
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)
// 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 = new Breaker { Name = "api" },
};In this migration, Attempts represents the total number of calls, so MaxRetryAttempts = 2 becomes Attempts = 3. The status-code and exception predicates are handled by a classifier, which is pre-configured for HTTP in the Resilience.Http preset.
Implement a fallback
Before (Polly)
.AddFallback(new FallbackStrategyOptions<string>
{
FallbackAction = _ => Outcome.FromResultAsValueTask("cached"),
})After (NResilience)
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 implemented as an if check on the CallResult. Implementing this at the call site makes it clear whether the value came from the dependency or from the fallback.
Register the policy
Before (Polly)
services.AddHttpClient<Client>().AddStandardResilienceHandler();After (NResilience)
services.AddHttpClient<Client>().AddResilience();Configure predicates
Before (Polly)
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r => r.StatusCode == HttpStatusCode.Conflict),After (NResilience)
// 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,
Classify = Classifier.Http.OnResult<HttpResponseMessage>(r =>
r.StatusCode == HttpStatusCode.Conflict ? Verdict.Transient : Classifier.Http.ClassifyResult(value: r)),
};Implement bulkhead isolation
Before (Polly)
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddBulkhead(handledByEntityKey => 10) // max 10 concurrent calls
.Build();After (NResilience)
// For HTTP clients via dependency injection
services.AddHttpClient<PaymentClient>()
.AddResilience()
.AddRateLimit(options => options.Concurrency = 10);
// For any other callback
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 prevents one slow dependency from monopolizing your thread pool. In NResilience, Limit.Concurrency achieves 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 as
Verdict.Throttled(SelfImposed: true), which are retried on the long backoff curve and never open the breaker - For HTTP, scoped per host by default (like circuit breakers)
For a complete guide with real-world examples, see Resource isolation with bulkheads.
Handle exceptions and state
Before (Polly)
ExecuteAsync wraps failures and passes state through a rented ResilienceContext.
After (NResilience)
// 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 is returned unchanged, so existing catch blocks continue to work. Instead of a context object, use the TState execution overloads to pass your own state to the callback, which also allows the lambda to be static.
Behavioral differences
When migrating, be aware of these four behavioral differences:
- Limited HTTP retries:
Classifier.Httptreats all 4xx status codes as answers, except 408 and 429. The HTTP handler does not retryPOSTorPATCHrequests unless you explicitly mark the request as repeatable. - Unrecognized exceptions:
Classifier.Defaulttreats unknown exception types asPermanent. If you require a broad handler, useClassifier.RetryEverything. - Active retry budget: By default, retries are capped at 10% of successful traffic per policy. A load test against a dead dependency will return
StopReason.BudgetExhausted. UseRetryBudget.Noneto disable this. See the Retry budget guide for details. - Refusal pause: An open circuit breaker pauses for 100 milliseconds before reporting a failure. This prevents the breaker from becoming a load generator. See Guarded rejection.
Run NResilience and Polly together
You can run both libraries in the same process. Because the metric names, tag names, and event names do not overlap with Polly's vocabulary, you can distinguish between them in your dashboards. This allows you to migrate clients one at a time.
