One policy per tenant
Avoid the "noisy neighbor" problem. Learn how PolicyScope gives each tenant, shard, or channel its own breaker, budget, and latency estimate.
TL;DR: Shared breakers mean one noisy tenant can trip the circuit for everyone. PolicyScope<TKey> isolates resilience state per key, ensuring that a failure in one tenant's partition doesn't stop traffic for all other tenants.
The Blast Radius Problem
In a multi-tenant application, you often use a single shared dependency (like a database or a third-party API) to serve all your customers.
If you use a single Breaker for that dependency, you have a shared blast radius. If Tenant A sends a burst of malformed requests that trigger a series of failures, they will trip the breaker. Suddenly, Tenant B - who is behaving perfectly - finds their requests being refused because the breaker is open.
This is the "noisy neighbor" problem. Tenant A's failure has effectively caused an outage for Tenant B.
Isolation via PolicyScope
To fix this, you need to scope your resilience state. You don't want one breaker; you want one breaker per tenant.
PolicyScope<TKey> allows you to define a template policy and then derive a specific instance of that policy for any given key.
// Define a template: a standard policy with a breaker
private static readonly PolicyScope<string> Tenants = new(
Resilience.Default with { Breaker = new Breaker() });
// Use the policy for a specific tenant
await Tenants.For(tenantId).RunAsync(ct => Work(ct), cancellationToken);
When you call Tenants.For(tenantId), NResilience does something critical: it doesn't just give you the template. It creates a keyed instance of the policy. This instance has its own:
- Circuit Breaker: Tenant A's failures only trip Tenant A's breaker.
- Retry Budget: Tenant A cannot exhaust the retry tokens for Tenant B.
- Latency Estimate: A slow partition for one tenant doesn't lower the hedge threshold for everyone else.
Guarding the Memory
If you have millions of tenants, you cannot have millions of breakers in memory. An unbounded dictionary of state is a memory leak.
PolicyScope is bounded by construction - there is no unbounded mode, because every entry carries a breaker and a budget. You can set maximumKeys when you create the scope (default 1024). Eviction is approximate: keys seen since the last sweep survive the next one, so the scope can briefly exceed its cap, but no lookup ever waits on a sweep.
If a tenant is evicted and then returns, they get a fresh policy with a fresh breaker. This means the "memory" of their failure is discarded, but it also ensures your process memory stays bounded regardless of how many unique tenants you serve. Size maximumKeys above the number of tenants you expect to be active at once.
Advanced Shaping
Sometimes, "one size fits all" isn't enough. You might have a "Premium" tenant who is allowed a longer deadline or a higher retry count than "Free" tenants.
You can pass a shape function to the PolicyScope constructor to customize the policy for each key on first sight:
private static readonly PolicyScope<string> Tenants = new(
template: Resilience.Default,
shape: tenantId => tenantId.StartsWith("premium_")
? Resilience.Default with { Deadline = TimeSpan.FromSeconds(30) }
: Resilience.Default);
Because the shaping happens once and the result is cached, this adds zero overhead to the hot path.
Summary
Resilience is about isolation. By moving from a single global policy to a keyed PolicyScope, you ensure that failures are contained. You protect your healthy tenants from your noisy ones, and you ensure that your resilience state is as granular as your architecture requires.
The built-in policies cover most cases, but real-world dependencies sometimes need custom logic. Bending the library covers the extension points - Admit, BeforeAttempt, Backoff.Custom, and OnEvent - that let you inject behavior without breaking the flat executor.