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
| 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), or Limit.Adaptive to discover the number instead of picking it |
AddFallback | if logic on a CallResult<T> |
AddHedging | Hedge, against a live latency quantile rather than a fixed delay |
ShouldHandle predicates | One Classifier used by all strategies |
ResilienceContext, ResilienceProperties | TState execution overloads |
ResiliencePipelineProvider<string> | IResiliencePolicies |
AddResiliencePipeline("name", ...) | services.AddResilience("name", ...) |
AddStandardResilienceHandler() | .AddResilience() |
A hand-rolled retrying gRPC Interceptor | .AddGrpcResilience() |
ShouldHandle over RpcException.StatusCode | GrpcResilience.Classifier, overridable one line at a time |
A CallOptions.Deadline you compute per call | Deadline 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 start | AddGrpcResilience(), which retries a server stream until the first message and never after it |
OnRetry, OnTimeout, OnOpened, etc. | One OnEvent listener |
resilience.polly.* metrics | nresilience.* 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)
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 = 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)
.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 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)
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,
Classifier = 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
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)
// 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.Httptreats all 4xx statuses as answers, except 408 and 429. The HTTP handler does not retryPOSTorPATCHunless you mark the request repeatable. - Unrecognized exceptions:
Classifier.Defaulttreats unknown exception types asPermanent. For 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 returns
StopReason.BudgetExhausted. Disable withRetryBudget.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
MaxHedgedAttemptsalongside retry'sMaxRetryAttempts, and the product is the real ceiling on load. HereAttemptsis the total number of calls that reach the dependency whatever shape they run in, andHedge.MaximumConcurrentbounds only how many overlap. MigratingMaxRetryAttempts = 2plusMaxHedgedAttempts = 2means 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.
