Shipping a resilience library
A meta-look at the quality gates, analyzer bundling, and CI processes that ensure NResilience's promises of performance and correctness.
TL;DR: Shipping a library that promises "measurable performance" and "correctness" requires more than just a test suite. This post reveals the internal machinery of NResilience: the five CI gates, the decision to bundle analyzers internally, the API manifests that make surface changes reviewed diffs, and a documentation pipeline that treats code samples as executable tests.
The Promise vs. The Proof
When a library claims to be "allocation-free" or "correct by design," those are bold assertions. In a distributed system, a tiny regression in a core loop can cause a p99 spike that takes down a production cluster.
To move from "asserted" performance to "measured" performance, you cannot rely on manual benchmarks. You need a system of gates that make it impossible to merge a regression.
The Five Gates of CI
NResilience's CI pipeline isn't just about "passing tests." It is a series of five specialized gates that must be cleared before any code hits the main branch.
1. The Behavior Gate
The standard functional suite. It ensures the logic is correct across thousands of permutations of retries, deadlines, and breakers.
dotnet test tests/NResilience.Tests/...
2. The Allocation Gate
This is the most critical gate for performance. We use GC.GetAllocatedBytesForCurrentThread() in Release builds to enforce strict memory ceilings. If a change adds even a few bytes to the core execution path, the build fails.
dotnet test tests/NResilience.Gates/...
3. The Integration Gate
Real-world verification. This gate runs loopback tests against actual HTTP and gRPC listeners to ensure the library survives the "wire" (TCP resets, timeouts, and partial writes).
dotnet test tests/NResilience.IntegrationTests/...
4. The Analyzer Gate
Since the library provides diagnostics to help users avoid bugs, the diagnostics themselves must be bug-free. This gate tests the analyzers against a suite of broken and fixed code samples.
dotnet test tests/NResilience.Analyzers.Tests/...
5. The Native AOT Gate
To guarantee AOT compatibility, we don't just check for warnings. We publish a native binary and execute it. If the published binary crashes or fails to bind configuration, the gate closes.
dotnet publish ... -p:PublishAot=true
Bundling the Intelligence
A common pattern for .NET libraries is to ship analyzers in a separate NuGet package (e.g., NResilience.Analyzers). We rejected this.
A diagnostic that isn't installed is a diagnostic that doesn't exist. If a user misses the analyzer package, they will ship a policy with a 30s deadline and a 90s retry loop, and it will fail silently in production.
By bundling the analyzers internally in the main package (analyzers/dotnet/cs), we ensure that the moment you add the library, the compiler starts reviewing your resilience design. The diagnostics travel with the code.
API Manifests and Pinned Dependencies
To prevent "API drift," NResilience uses manifest-based reviews. Every change to the public surface is tracked in PublicAPI.Shipped.txt. When a dev changes a method signature, they must update the manifest. This turns a casual code change into a deliberate architectural decision during the PR review.
We apply the same rigor to dependencies via Directory.Packages.props:
- Competitive Baselines: Dependencies like
Polly.Coreare pinned. We only bump them when we have re-measured our performance claims against the new version - which is what the sustained-load stress suite inbench/NResilience.Stressis for. - Lockstep Pinning: All
Grpc.*packages are pinned to the exact same version to prevent the notorious "mixed version" crashes common in gRPC-dotnet. - Compatibility Pinning: Roslyn (
Microsoft.CodeAnalysis) is pinned low to ensure our analyzers load in as many compiler versions as possible.
The Docs-to-Code Pipeline
The most frustrating part of using a library is a code sample in the docs that doesn't compile.
NResilience treats documentation as code. Every snippet in our markdown files is actually extracted from a real .cs file in tests/NResilience.Docs, marked up with snippet: tags.
The pipeline works like this:
- Verification: CI runs
dotnet teston the docs project. If a sample fails, the build fails. - Extraction: A custom tool extracts code between
// <snippet:name>markers. - Synchronization: The tool scans the markdown for matching tags. If the markdown content differs from the source code, the CI fails with a "drift" error.
This ensures that the documentation is never a "best guess" - it is a verified, executable truth.
Conclusion
Shipping a resilience library is a lesson in humility. You are building the safety net for other people's systems. By treating performance as a CI gate, analyzers as a core product, and documentation as executable code, we ensure that NResilience's promises are backed by evidence.
The GitHub Repository has the full CI pipeline and source. For the sustained-load evidence behind the allocation and GC claims - a full NResilience-vs-Polly run with every repetition published - Stress-tested against Polly is the summary. To close the series, Beyond the basics synthesizes the core lessons and outlines the road to v1.0.