The small decisions that add up
An essay on the deliberate design choices in NResilience, from guarded rejections to live quantiles and the anatomy of a Verdict.
TL;DR: Great software isn't just about the big architecture; it's about the small, defensive decisions that prevent catastrophic failures. This post explains the "hidden" logic in NResilience: why a tripped breaker pauses for 100ms, how "self-imposed" throttles protect your retry budgets, and why live quantiles are the only safe way to hedge.
The Philosophy of the Small Decision
When you build a resilience library, you aren't just writing code to handle errors; you are writing code that manages the relationship between two systems under stress. In that environment, "standard" logic often fails.
A simple while loop for retries is fine for a demo, but in production, that loop can become a weapon. If not carefully guarded, a resilience library can accidentally amplify a brownout into a total outage. To prevent this, NResilience is built on a series of small, deliberate decisions - most of them invisible to the user - that ensure the library remains a protector, not a load generator.
Guarded Rejection: Breaking the Spin
When a circuit breaker trips, it enters the Open state. In a naive implementation, every call to an open breaker returns a "Failed" result immediately.
This sounds efficient, but it creates a dangerous edge case: the CPU Spin. If a developer accidentally puts a resilient call inside a tight while loop (or a polling mechanism) without any delay, an open breaker will return thousands of failures per second. This consumes 100% of the CPU, starving other parts of the application and potentially crashing the process.
NResilience implements Guarded Rejection. When a breaker is open, the library pauses for 100ms before reporting the refusal. The pause is capped by whatever is left on the deadline and observes your cancellation token, so a refusal never pushes a call past the caller's budget, and cancelling during the pause aborts immediately.
This pause is non-configurable on purpose. It exists to set a floor under the rate of a rejection loop, not to be a tunable performance parameter. It is short enough that a human user won't notice it, but long enough to make a CPU spin impossible. It turns a potential crash into a manageable delay. For callers who manage their own retries, CallRejectedException.RetryAfter says when to come back.
Admission Control and the "Self-Imposed" Bit
In NResilience, every attempt ends with a Verdict. Whether it's a 503 from a server or a timeout from the library, the outcome is mapped to a standard verdict (Ok, Transient, Throttled, or Permanent).
But there is a critical distinction when the failure comes from inside the library - for example, when a ConcurrencyLimiter refuses a permit.
If a limiter refuses a call, NResilience classifies it as Verdict.Throttled with a self-imposed flag. This distinction is vital for two reasons:
- The Breaker ignores it: If your own limiter is refusing calls because you've hit your concurrency cap, that isn't a sign that the dependency is failing. It would be a bug if your local limiter tripped your circuit breaker.
- The Budget ignores it: Retry budgets are designed to prevent you from overwhelming a struggling dependency. If you are throttled by your own internal limiter, you aren't adding load to the server. Therefore, self-imposed refusals are exempt from the retry budget.
The executor enforces this from the inside: a RateLimitedException is always classified as self-imposed, and no classifier rule can turn a refusal this process imposed on itself into evidence against the dependency. Value equality follows the flag too - Verdict.Throttled() and Verdict.Limited() carry the same kind but are not equal.
By distinguishing between "the server is full" and "I am full," NResilience ensures that your resilience settings don't fight each other.
Why Live Quantiles are the Only Safe Hedge
Hedging - sending a duplicate request when the first one is slow - is the sharpest tool for killing tail latency (p99). It is also the easiest to misuse.
The classic approach is a fixed delay: "If the call takes more than 200ms, send a second one." This is a trap. If your dependency slows down and every call starts taking 210ms, you have just doubled your traffic to a service that is already struggling. You've turned a brownout into a retry storm.
NResilience uses a live latency quantile (e.g., p95). Instead of a fixed 200ms, the library tracks the actual latency of recent calls. If the p95 is 10ms, the hedge fires at 10ms. If the dependency slows and the p95 moves to 2s, the hedge fires at 2s.
Because the threshold moves with the traffic, the fraction of calls that hedge remains constant (roughly 5%). The load on the dependency is stable, regardless of whether it is responding in milliseconds or seconds.
Data-Driven Design: Verdicts and Events
To keep the library performant, the internal data structures are stripped to the bone.
The Verdict is a 16-byte struct. It has to be: a verdict is live across the attempt await, so every byte it carries is paid in the state-machine box of every suspending call in the library, whether or not anything is ever throttled. That is why the self-imposed flag shares the kind's byte rather than sitting beside it as a bool, and why RetryAfter is a biased long rather than a TimeSpan? - each packing saves eight bytes for everyone.
Furthermore, NResilience follows a strict "One Event Per Call" rule. While a call might have five attempts, you only ever receive one terminal CallEvent. This event summarizes the entire journey: the final verdict, the number of retries, and the total delay. This prevents your telemetry and logs from being flooded by "Attempt X started" noise, giving you a clean, high-signal view of your system's health.
Conclusion
Resilience is not about adding more features; it's about removing ways for the system to fail. Guarded rejections, self-imposed throttles, and live quantiles are small decisions, but they are the difference between a library that "works in a demo" and a library that survives a production outage.
The Classification and Verdicts guide documents every outcome. For the story of how these decisions are enforced as the library ships, Shipping a resilience library covers the five CI gates, analyzer bundling, and the docs-to-code pipeline.