Measured, not guessed
Stop guessing your timeouts and retry counts. Learn how NResilience uses adaptive bounds to measure dependency latency and error rates in real time.
TL;DR: The hardest part of resilience is picking the right numbers. NResilience replaces guesswork with measurement: it tracks recent p95 latency and error rates to automatically tighten your bounds, ensuring you fail fast during a brownout without manually tuning constants for every dependency.
The "Magic Number" Problem
Every experienced engineer has a folder of "magic numbers" in their config files:
PaymentApi_Timeout: 2sInventoryApi_RetryCount: 3SearchApi_BreakThreshold: 0.1
The problem is that these numbers are almost always wrong. A 2-second timeout might be perfect on Tuesday, but if the dependency's p99 latency climbs to 2.1 seconds on Wednesday, your app suddenly starts failing every single request - even though the dependency is still healthy, just slightly slower.
If you set the numbers too high, you're not protected against brownouts. If you set them too low, you're killing healthy traffic.
The Rule: Constant and Measured
NResilience solves this with a single, unifying rule: Bounds can be stated as constants or measured from the dependency, and when both exist, the tighter one wins.
The measured term never loosens what you wrote. A Deadline is pure constant - measurement never touches it, so a 10-second deadline is a 10-second deadline whatever the dependency does. What measurement tightens are the bounds that sit inside it: the per-attempt ceiling, the breaker's trip points. If the dependency suddenly slows down, the measured term kicks in and fails a call at 2 seconds, long before your "safe" 10-second constant is reached.
The Three Adaptive Pairs
The library applies this logic to the three most critical areas of resilience.
1. The Attempt Ceiling
Instead of a fixed AttemptTimeout, the library tracks the recent p95 latency of successful calls. By default, it sets the ceiling at 3x the recent p95.
If the dependency normally responds in 100ms, your attempt timeout is effectively 300ms. If it slows to 500ms, your timeout automatically shifts to 1.5s. You get the protection of a tight timeout without the fragility of a constant.
2. Adaptive Breakers
Circuit breakers usually trip on a fixed error ratio (e.g., 10%). But for a high-reliability API, a 10% error rate is a catastrophe; for a flaky legacy system, it's a Tuesday.
NResilience measures the normal error rate of the dependency. It then trips the breaker if the current rate spikes to a multiple of that baseline (e.g., 5x normal). The breaker adapts to the "noise floor" of the dependency.
3. Adaptive Concurrency (The Bulkhead)
Picking a concurrency limit (bulkhead) is a guessing game based on how many pods you have. If you have 10 pods and a dependency that handles 1,000 concurrent calls, you set a limit of 100. But what happens when you scale to 50 pods? You've just quadrupled the load on the dependency.
Limit.Adaptive discovers the limit by watching for queueing. When latency spikes while the limit is high, the limit shrinks. When latency is stable, it probes upward. The limit stays correct regardless of your pod count.
// No permit count to pick. The ceiling is the one number worth setting per dependency:
// it is what the loop cannot exceed when the measurement is wrong.
using var limiter = Limit.Adaptive(maximum: 200);
Every parameter is optional - Limit.Adaptive() on its own is a complete limiter - and the rest of the knobs (the floor, how much slower than baseline counts as queueing, how hard a congested round backs off) live on an AdaptiveLimitOptions you pass as options when you want them.
From Cold Start to Steady State
You might wonder: "If the library measures latency, what happens when the app first starts?"
A "cold" policy behaves exactly like a constants-only process. The measured terms stay invisible until they have a sufficient baseline of samples (the MinimumSamples threshold).
- Cold Start: Only your constants (e.g.,
Deadline = 30s) are enforced. - Warming Up: The library samples successful attempts.
- Steady State: The measured bounds kick in, tightening the ceiling around the actual behavior of the dependency.
When the Measurement Measures the Wrong Thing
Everything above rests on one assumption, and it is worth saying out loud: that the number being measured is a fact about the dependency.
It is measured with a wall clock around your callback. So it is really a fact about everything between calling the dependency and getting an answer back - and one of those things is your own thread pool.
Picture a process whose pool is starved. Work items are queuing 400ms before they reach a thread. From inside the executor that is indistinguishable from a dependency that got 400ms slower, and every measured term responds accordingly:
- The attempt ceiling rises, so a doomed attempt runs longer.
- The measured backoff base lengthens, so the retry that would have worked waits.
- The hedge threshold climbs out of reach, so hedges stop firing.
All three loosen, at the same moment, and the moment is a local incident. Meanwhile the breaker sits closed and the retry budget sits full, because both of them sample the dependency - and the dependency is fine.
That is a defect in the adaptive story rather than a missing feature, and thread-pool starvation is one of the most common causes of a .NET outage. So there is a switch for it:
var api = Resilience.Http with
{
AttemptCeiling = AttemptCeiling.Above(multiple: 3),
// Five times this process's own normal queue delay. Above that, the three terms above stop
// being fed - they hold what they last learned until the queue drains.
Saturation = Saturation.Above(multiple: 5),
};
Saturation.Above(5) is dimensionless on purpose, like every other number in this post. "Five times its own normal" survives being copied to a pod with a different core count; "200ms" does not. A Floor of 20ms sits under it, because a healthy pool queues in microseconds and five times microseconds is still microseconds - nobody wants a policy that stops learning every time the garbage collector moves one probe.
What it does is narrow, and deliberately so: it only declines to record. Nothing is refused, no bound moves, no delay is added. A policy that finds itself saturated makes the same attempts, in the same shape, with the same verdicts. The worst case is behaving exactly as it does without the switch, which is what makes it safe to turn on.
The measurement behind it is one process-wide probe: a work item stamped with a timestamp, queued into the global pool queue, measured when it runs. Four times a second, read as a couple of volatile loads, and a process that never configures this never queues one. And when the pool is deep enough that the probe itself cannot run, the probe's own overdue-ness is the reading - which is the whole trick, because anything that waited for the callback would report the microsecond it measured before the incident started.
It is off by default, and not because it could do harm. It changes what every other measured term in the policy learns, and a change of that shape earns its own deliberate rollout.
One honest failure mode, stated the way the adaptive limiter states its own: a process that is always saturated learns a high queue delay as normal and stops finding itself saturated. The baseline is relative, so all it can tell you is that this minute is unlike the last hour. It keeps a local incident from corrupting your dependency measurements. It is not a substitute for having enough threads.
Reading What It Measured
An adaptive bound you cannot see is an adaptive bound you cannot trust. Every measured term the policy holds is readable from one property:
var reading = policy.Measured;
reading.AttemptCeiling; // what the ceiling currently measures to, before AttemptTimeout clamps it
reading.BackoffBase; // the base delay the next transient retry would wait, after the spread clamp
reading.HedgeThreshold; // the latency at which a hedge arms - where extra load starts
reading.QueueDelay; // how long a work item waits for a thread in this process's pool
Each one is null until its feature is configured and its estimate is warm, which is the cold-start behaviour above, stated as a value you can check. They are readings, never configuration: the knobs that produce them are AttemptCeiling, Backoff.MeasuredBase, Hedge and Saturation, and those keep the plain nouns.
For the whole picture at once, policy.Explain() prints them beside the configuration that produces them, and labels each one cold or warm:
Measured now: attempt ceiling 1.97s 3x p95 over 5m, 20 samples minimum
backoff base - not configured
hedge threshold - cold - p95 over 30s, 20 samples minimum
pool queue delay - not configured
Three states, told apart: a warm reading, a term nobody configured, and a configured term that has not seen enough traffic to say anything yet. That last distinction is the one every support question turns on. "Configured" and "in effect" are different facts, and a policy that reports only the first one is a policy you are reading half of.
Measured.HedgeThreshold is the one worth putting on a dashboard first. It is the latency at which the library starts duplicating requests, so it explains the extra traffic beside it - and because it is a live quantile, watching it move during an incident tells a brownout from a tail.
Put Measured.QueueDelay next to it. It is the only reading here that describes your process rather than the dependency, and when it moves with the other two, the dependency did not get slower and this host did. The nresilience.pool.delay histogram records the same number at the onset of each episode, so a count of samples is a count of local incidents.
The estimates are private to the policy instance. The HTTP handler derives one policy per host, so each host is measured on its own and a slow host never tightens a fast one's ceiling. QueueDelay is the exception: there is one thread pool, so every policy in the process reports the same number for it.
Opting Out
Some scenarios require absolute determinism, like a strict performance benchmark. You can disable all adaptive behavior with a single switch:
var deterministicPolicy = Resilience.Default with
{
Adaptive = false
};
Setting Adaptive = false on the policy turns off the measured attempt ceiling and every other measured term the library would supply. You are left with exactly the constants you wrote - no more, no less. Saturation is refused alongside it rather than ignored: it decides when to stop measuring, so a policy that measures nothing has nothing for it to do, and the library says so instead of picking a winner.
One boundary on the switch: it deliberately stops at the breaker. A breaker is a live object two policies may share, and a policy switch that silently muted a shared breaker would surprise whoever holds the other reference. Give the breaker's settings their own Adaptive = false if you want it constants-only too. In configuration there is no such sharing, and a single "Adaptive": false JSON key covers both.
Summary
Resilience shouldn't be a game of "guess the number." By composing constants with real-time measurements, NResilience gives you the safety of a hard ceiling and the efficiency of an adaptive one. You define the absolute limits of what you'll tolerate, and the library handles the nuance of the dependency's actual performance.
The most visible benefit of measured bounds is shorter tail latency. Race the tail explains how hedging uses live quantiles to send a duplicate request when the first one is slow, cutting p99 without the risk of a retry storm.