Bound what leaves the process
Learn how to use rate limiters and bulkheads to bound the absolute rate and concurrency of your outbound calls before they fail.
TL;DR: A circuit breaker reacts to failure, and a retry budget bounds retries. But a limiter bounds the absolute rate or concurrency of what leaves your process - before anything has gone wrong. Use Limit.PerSecond for quotas and Limit.Concurrency for bulkheads to protect your dependencies from unexpected surges.
Guarding the Exit
Up to this point, we've focused on how to handle failures: retrying, timing out, and breaking the circuit. But there is a different kind of guard you need: Admission Control.
If you know your dependency has a hard quota of 100 requests per second, you don't want to find that out via a wall of 429 Too Many Requests errors. You want to bound the traffic before it leaves your process.
A limiter is the gatekeeper. It ensures that no matter how many threads are trying to make calls, the aggregate output of your process stays within a safe bound.
Before reaching for one over HTTP, check whether you need to know the number at all. Most large APIs publish their rate limit in response headers. The handler reads these and refuses an attempt once the remaining allowance is inside the reserve, so you don't have to supply a rate or keep it in sync. See HTTP in depth. A limiter is the answer when the dependency publishes nothing, when you are bounding something other than HTTP, or when what you need is a concurrency bound rather than a rate.
Three Ways to Limit
NResilience provides four limiter shapes, three of them built on the platform's System.Threading.RateLimiting types for a familiar developer experience.
1. Rate Limiting (Limit.PerSecond)
Used for quotas. It ensures you don't exceed a specific number of calls per second. When the limit is hit, the refusal surfaces as a RateLimitedException, which the executor classifies as Throttled with the self-imposed flag and retries on the long backoff curve.
var limiter = Limit.PerSecond(100);
2. Windowed Limiting (Limit.PerWindow)
For longer quotas (e.g., 1,000 calls per hour). NResilience uses a sliding window rather than a fixed boundary. This prevents "boundary spikes," where a client spends their entire quota at the end of one window and the start of the next, effectively doubling the nominal rate.
var limiter = Limit.PerWindow(1000, TimeSpan.FromHours(1));
3. Concurrency Limiting (Limit.Concurrency)
The "Bulkhead" pattern. This doesn't care about the rate; it cares about how many calls are in flight at once. This is critical for preventing a slow dependency from saturating your entire thread pool. If you allow 20 concurrent calls, the 21st caller is refused immediately, keeping the rest of your app responsive.
var limiter = Limit.Concurrency(20);
Integrating with the Executor
The most important rule of limiting is where you acquire the permit.
You must acquire the permit inside the callback you hand to RunAsync. If you acquire it outside, a retry will bypass the limiter, and you'll be spending one permit for an operation that might actually make three calls.
using var limiter = Limit.PerSecond(permits: 100);
await policy.RunAsync(async attempt =>
{
// Acquire the permit inside the attempt. `ct` here is the attempt's token,
// already bounded by the attempt timeout and the remaining deadline.
using var lease = await limiter.AcquireOrThrowAsync(cancellationToken: attempt);
return await client.GetAsync(url, attempt);
}, cancellationToken);
By placing the acquisition inside the callback, the limiter becomes part of the "attempt." If the limiter refuses the call, NResilience classifies it as a self-imposed Throttled verdict, handles the backoff, and retries - all while staying within the absolute bound. Because the attempt's token is already bounded by min(AttemptTimeout, time left on the deadline), a limiter that waits is bounded by the policy's own time budget with nothing further to configure.
There is a fourth shape worth knowing: Limit.Adaptive(), a bulkhead that discovers its own limit from latency instead of asking you to guess one. Like the other three, it is one line at the call site - every parameter has a default. We cover it in the next post.
Limiter vs. Retry Budget
It's easy to confuse these two, but they solve different problems:
| Feature | What it bounds | Trigger | Purpose |
|---|---|---|---|
| Limiter | Absolute Rate / Concurrency | Always | Prevent overloading the dependency. |
| Retry Budget | Aggregate Retries | Only on failure | Prevent retry storms during outages. |
A limiter is a constant ceiling. A retry budget is a dynamic valve that closes when the dependency is struggling. You often need both: a limiter to respect the quota and a budget to survive the storm.
Summary
Limiting is about predictability. By bounding the absolute rate and concurrency of your outbound traffic, you ensure that your application is a predictable neighbor in a distributed system.
Next up is the signature idea of NResilience: how to move from guessing these bounds to measuring them from the dependency's actual behavior.
Measured, not guessed covers the adaptive bounds that track real latency and error rates.