A modern resilience library for .NET. Retry, circuit breaker, timeout, fallback, rate limiting, hedging, and pipeline composition — with zero external dependencies in the core package.
Free forever. Resilion has no paid tier, no "enterprise edition," and no plans to add one. If it's useful to you, consider buying us a coffee — never a requirement, always appreciated.
using Resilion;
// Create a resilience pipeline with multiple strategies
var pipeline = Pipeline.Create(b => b
.AddTimeout(TimeSpan.FromSeconds(30))
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)),
})
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatioThreshold = 0.5,
MinimumThroughput = 10,
})
.AddTimeout(TimeSpan.FromSeconds(5)));
// Execute with the pipeline
var result = await pipeline.ExecuteAsync(
static (httpClient, ct) => httpClient.GetStringAsync("https://api.example.com/data", ct),
new HttpClient());Resilion is designed for .NET developers who want powerful resilience patterns without complexity:
Coming from Polly? See docs/migration-from-polly.md for a concept map and before/after code samples for the five most common patterns, or docs/comparison-with-polly.md for an honest side-by-side on where each library is stronger today.
The Resilion package has zero external dependencies. Everything you need for production resilience is built-in.
One Pipeline.Create() call. Chain strategies. Build. That's it. No framework to learn, no builder patterns, no magic.
var pipeline = Pipeline.Create(b => b
.AddRetry(options)
.AddCircuitBreaker(options)
.AddTimeout(duration));Every built-in strategy has a real synchronous implementation on its own execution path —
Thread.Sleep and WaitHandle, not .GetAwaiter().GetResult() over the async path. This matters
on WinForms/WPF UI threads, ASP.NET Framework, and anywhere blocking on an async call risks
deadlock.
Four limits, so you can tell whether this covers your case:
- An async fallback factory (
FallbackActionAsync) or an async event handler (OnRetry,OnTimeout, …) is still async work being run from a sync call. When aSynchronizationContextis present, Resilion hops to the thread pool to run it — that hop is what avoids the deadlock, but it is sync-over-async. Supply synchronous handlers (Action<TArgs>, which is the default shape) and this never happens. - Sync hedging is sequential-only. Parallel and latency modes need real concurrency, so
Executethrows for them rather than silently degrading. - Sync retry delays are not driven by
TimeProvider, so they can't be advanced byFakeTimeProviderin tests (future-plans #9). - A custom strategy must override
Execute; the base implementation throws under aSynchronizationContextrather than risking a deadlock on your behalf.
// Synchronous execution
var result = pipeline.Execute(state => DoWork(state), httpClient);
// Asynchronous execution
var result = await pipeline.ExecuteAsync(async (state, ct) =>
await DoWorkAsync(state, ct), httpClient);No PredicateBuilder or PredicateResult chains. Just Func<Outcome<T>, bool>:
var pipeline = Pipeline.Create<HttpResponseMessage>(b => b.AddRetry(
new RetryStrategyOptions<HttpResponseMessage>
{
MaxRetryAttempts = 3,
ShouldHandle = outcome =>
outcome.Exception is HttpRequestException // Handle exceptions
|| (outcome.TryGetResult(out var r) && (int)r.StatusCode >= 500), // or result-based
}));Assign callbacks directly. No ValueTask wrapping for synchronous logging:
Action<RetryAttemptEvent> onRetry = context =>
logger.LogWarning("Retry attempt {Attempt}", context.AttemptNumber);
new RetryStrategyOptions { OnRetry = onRetry };The handler's type has to be stated. ResilienceEventHandler<TArgs> converts implicitly from
Action<TArgs> and Func<TArgs, ValueTask>, but it is not itself a delegate type, so C# will not
convert a bare lambda (CS1660) or a method group (CS0428) to it. A local, an inline cast, or
new Action<TArgs>(...) all work:
new RetryStrategyOptions
{
OnRetry = new Action<RetryAttemptEvent>(context =>
logger.LogWarning("Retry attempt {Attempt}", context.AttemptNumber)),
};An async handler is the same shape with Func<TArgs, ValueTask>. The saving over always-async
callbacks is real — a synchronous handler runs inline, with no ValueTask wrapping — but it costs
you the one-liner.
Combine pre-built pipelines into larger ones:
var basePipeline = Pipeline.Create(b => b
.AddRetry(retryOptions)
.AddCircuitBreaker(cbOptions));
var fullPipeline = Pipeline.Create(b => b
.AddRateLimiter(rlOptions)
.AddPipeline(basePipeline));Outcome<T>is a structResilienceContextis pooled- State parameters avoid closures
- Designed for performance-critical paths
dotnet add package Resiliondotnet add package Resilion.Extensionsdotnet add package Resilion.RateLimitingdotnet add package Resilion.Http| Package | Purpose | Dependencies |
|---|---|---|
| Resilion | Core library with all built-in strategies, metrics and spans | None |
| Resilion.Extensions | DI registration, pipeline registry, reload, IConfiguration binding |
Microsoft.Extensions.DependencyInjection.Abstractions, Microsoft.Extensions.Options |
| Resilion.RateLimiting | Rate limiting strategy with multiple algorithms | System.Threading.RateLimiting |
| Resilion.Http | HttpClient / IHttpClientFactory resilience handlers |
Microsoft.Extensions.Http, Resilion.Extensions, Resilion.RateLimiting |
Automatically retry failed operations with customizable delay strategies.
var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)),
UseJitter = true, // Decorrelated jitter (on by default)
}));Delay Strategies:
Exponential— Exponential backoff: 1s, 2s, 4s, 8s, ...Linear— Linear backoff: 1s, 2s, 3s, 4s, ...Constant— Fixed delay between retriesCustom— Supply your own delay function
Result-Based Retry on typed pipelines:
var pipeline = Pipeline.Create<HttpResponseMessage>(b => b.AddRetry(
new RetryStrategyOptions<HttpResponseMessage>
{
MaxRetryAttempts = 3,
Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)),
ShouldHandle = outcome =>
outcome.Exception is HttpRequestException
|| (outcome.TryGetResult(out var r) && (int)r.StatusCode >= 500),
}));Use when:
- Calling unreliable remote services
- Handling transient failures (network glitches, temporary outages)
- Need to respect rate limits with backoff
Enforce operation timeouts with cooperative cancellation.
var pipeline = Pipeline.Create(b => b.AddTimeout(TimeSpan.FromSeconds(10)));Key Points:
- Uses
CancellationToken— the operation must observe it - Perfect for async I/O operations
- Can be stacked (total timeout + per-attempt timeout)
Use when:
- Preventing indefinite hangs on remote calls
- Enforcing SLA boundaries
- Protecting against slow endpoints
Prevent cascading failures by stopping requests when failure rates are too high.
var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
FailureRatioThreshold = 0.5, // Trip at 50% failure rate
MinimumThroughput = 10, // Need 10+ calls before evaluating
SamplingDuration = TimeSpan.FromSeconds(30),
BreakDuration = TimeSpan.FromSeconds(30),
}));States:
- Closed — Normal operation, requests pass through
- Open — Too many failures, requests rejected immediately
- Half-Open — Testing if service has recovered
Use when:
- Protecting against cascading failures
- Integrating with dependent services
- Need fast-fail when a service is down
Provide a fallback value or action when operations fail.
var pipeline = Pipeline.Create<string>(b => b.AddFallback(
new FallbackStrategyOptions<string>
{
FallbackAction = "default-value", // Static value
}));
// Or with a function:
var pipeline = Pipeline.Create<string>(b => b.AddFallback(
new FallbackStrategyOptions<string>
{
FallbackAction = (context) => GetCachedValue() ?? "default",
}));
// Or async:
var pipeline = Pipeline.Create<string>(b => b.AddFallback(
new FallbackStrategyOptions<string>
{
FallbackActionAsync = async (context, ct) =>
await GetCachedValueAsync(ct) ?? "default",
}));Use when:
- You have a sensible default to return
- Calling a secondary data source
- Providing degraded service instead of failure
Control request throughput to prevent overload.
using Resilion.RateLimiting;
using System.Threading.RateLimiting;
var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 10,
QueueLimit = 0, // Reject excess requests
});
var pipeline = Pipeline.Create(b => b.AddRateLimiter(
new RateLimiterStrategyOptions { RateLimiter = limiter }));Built-in Limiters:
ConcurrencyLimiter— Limit concurrent operationsTokenBucketRateLimiter— Token bucket algorithmSlidingWindowRateLimiter— Sliding window rate limiting- Custom implementations via
System.Threading.RateLimiting
Use when:
- Limiting load on a resource
- Protecting downstream services
- Controlling API consumption
Send duplicate requests if the first one is slow, returning the fastest response.
var pipeline = Pipeline.Create<string>(b => b.AddHedging(
new HedgingStrategyOptions<string>
{
MaxHedgedAttempts = 3,
HedgingDelay = TimeSpan.FromSeconds(2),
}));Hedging Delay:
TimeSpan.Zero— Fire all requests in parallelTimeSpan.FromSeconds(2)— Wait 2 seconds before sending next requestSystem.Threading.Timeout.InfiniteTimeSpan— Sequential requests (no hedging, just fallback)
Use when:
- Reducing tail latency in latency-sensitive systems
- You can afford duplicate requests
- Calling idempotent endpoints
Strategies execute outermost to innermost. The recommended order is:
Pipeline.Create(b => b
.AddRateLimiter(...) // 1. Shed load FIRST
.AddTimeout(TimeSpan.FromSeconds(30)) // 2. Total timeout across all retries
.AddRetry(...) // 3. Retry failures
.AddCircuitBreaker(...) // 4. Track per-attempt success/failure
.AddTimeout(TimeSpan.FromSeconds(5))); // 5. Per-attempt timeoutWhy this order?
- Rate Limit prevents the system from being overwhelmed
- Outer Timeout sets a hard boundary for the entire operation
- Retry gives transient failures a chance to succeed
- Circuit Breaker protects downstream services and fast-fails when they're down
- Inner Timeout per-request prevents individual attempts from hanging
Different use cases may require different orderings — this is the safe default.
Register pipelines with Microsoft.Extensions.DependencyInjection:
using Resilion.Extensions;
services.AddResiliencePipeline("http-api", b => b
.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 })
.AddTimeout(TimeSpan.FromSeconds(10)));
// Later, resolve — inject IPipelineProvider<string> rather than the full registry if you
// only need to retrieve pipelines, not register new ones:
var provider = serviceProvider.GetRequiredService<IPipelineProvider<string>>();
var pipeline = provider.GetPipeline("http-api");
var result = await pipeline.ExecuteAsync(
async (client, ct) => await client.GetStringAsync(url, ct),
httpClient);Keyed resolution works too, with no Resilion-specific API:
public sealed class ApiClient([FromKeyedServices("http-api")] Pipeline pipeline) { }Pipelines can also be bound to IOptionsMonitor<T> and rebuilt when their configuration changes.
Two rules come with that, both load-bearing: resolve a reloadable pipeline per call rather than
caching it in a field, and keyed injection never observes a reload, because a keyed singleton
resolves once for the container's lifetime. See
docs/dynamic-reload.md, and
docs/configuration.md for binding strategy options straight from
appsettings.json.
Wrapping HttpClient is the most common use for a resilience library, so it is one line:
services.AddHttpClient("api").AddStandardResilienceHandler();That adds rate limiting (off by default), a total timeout, retry, a circuit breaker and a
per-attempt timeout, retrying 5xx, 408, 429, HttpRequestException and TimeoutRejectedException.
AddStandardHedgingHandler() swaps retry for hedging, and AddResilienceHandler(key, …) takes a
pipeline you build yourself.
Discarded responses are disposed for you, so a retry-heavy client does not exhaust its connection pool. See docs/http.md for request replay, hedging, what to catch, and the known limitations.
Resilion emits metrics on a "Resilion" Meter — retry attempts, circuit breaker state
changes, timeout expirations, fallback activations, hedging attempts, and rate limiter
rejections — with zero overhead until something subscribes. See
docs/telemetry.md for the full instrument list and how to subscribe with
MeterListener, dotnet-counters, or OpenTelemetry.
There is no ILogger integration: Resilion emits metrics and spans, and anything you want
logged goes through a callback on the strategy options, so the logger stays yours.
- .NET 8.0+
Resilion is built for modern .NET with full support for:
- Top-level statements
- Records and nullable reference types
- Async/await patterns
- Source generators (future features)
src/
Resilion/ Core library (zero dependencies)
Resilion.Extensions/ DI, pipeline registry, reload, configuration binding
Resilion.RateLimiting/ Rate limiting strategy
Resilion.Http/ HttpClient integration (DelegatingHandler)
tests/
Resilion.Tests/ Core strategy and pipeline tests
Resilion.Extensions.Tests/ DI and telemetry tests
Resilion.Http.Tests/ HttpClient handler tests
benchmarks/
Resilion.Benchmarks/ Performance benchmarks (BenchmarkDotNet)
samples/
Resilion.Samples/ Real-world usage examples
docs/
*.md Feature and architecture documentation
Resilion is designed for high-performance, latency-sensitive scenarios:
- Struct-based
Outcome<T>avoids allocating on the result path - Pooled
ResilienceContextfor request-scoped state ResilienceContextPooltrades ~14ns of bookkeeping for eliminating a 72-byte allocation per context- Benchmarks included (see
benchmarks/folder)
Measured on an Apple M4 Pro against Polly.Core 8.5.2, same pipeline shapes both sides (full results):
| Scenario | Resilion | Polly.Core | Notes |
|---|---|---|---|
| Empty pipeline | 70 ns / 96 B | 60 ns / 0 B | Resilion allocates per-strategy closures; Polly's happy path is allocation-free |
| Single retry (happy path) | 117 ns / 192 B | 169 ns / 0 B | Resilion faster in wall-clock time despite allocating |
| Composite (Timeout+Retry+CB+Timeout) | 488 ns / 1120 B | 740 ns / 0 B | The realistic multi-strategy shape most apps run |
| Same pipeline, sync execution | 53 ns / 192 B | — | Real sync path — no Task machinery |
The composite figure includes the cost of race-free timeout cancellation classification: 32 B per
timeout strategy, plus a CancellationToken registration whenever the token reaching that timeout
can be cancelled. A timeout nested inside another timeout always sees a cancellable token, so the
canonical total-plus-per-attempt shape above pays 144 B more than a version that misclassifies user
cancellation as a timeout under a race. See docs/tradeoffs.md.
Resilion is consistently faster wall-clock, at the cost of small per-call allocations from the middleware chain's closures (tracked in future-plans.md item #4). If your workload does any real I/O per call — the overwhelmingly common case — this difference is unlikely to be your bottleneck; if you're pushing hundreds of thousands of allocation-sensitive calls/sec with no I/O, benchmark your own shape before choosing.
See CONTRIBUTING.md for guidelines on:
- Code style and standards
- Testing requirements
- PR expectations
- Building and running tests locally
Built for developers who care about reliability.