Skip to content

Per-service scope

The HTTP handler scopes its circuit breaker per host, so one dead dependency does not trip calls to the healthy ones. A gRPC channel serves exactly one host, so there is no per-host scoping to do - but the blast-radius argument is the same one, and the gRPC unit it applies to is the service.

One expensive RPC failing should not open the circuit on every other method the client exposes. By default, each gRPC service gets its own circuit breaker, its own retry budget, and its own hedging latency estimate:

csharp
// One breaker and one budget per gRPC service by default, keyed by the service's full name -
// so an operator can be told which dependency opened, not merely that something did.
foreach (var (service, breaker) in interceptor.Breakers())
    Console.WriteLine($"{service}: {breaker.State}");

Change the key

ScopeBy takes an IMethod and returns the key:

ScopeByScopeUse when
static m => m.ServiceNamePer service. The default.The usual case.
static m => m.FullNamePer method.One method has failure modes the others do not share - an expensive report next to a cheap lookup.
nullOne scope for the whole client.The client fronts one coherent service and you want its breaker to see every call.

The registry is bounded by MaxScopes, which defaults to 1024 - far above the method count of any real service. Keys past that bound drop the least-recently-seen entries. There is no unbounded mode: unbounded keying is a memory leak with a breaker and a budget on every entry.

Where the breaker comes from

BreakerPerScope is on by default, so each scope gets a breaker built from BreakerSettings even though the shipped preset carries none. This mirrors what AddResilience() does per host, and it is why moving a client from HTTP to gRPC does not silently lose its breaker.

A policy that already carries a Breaker keeps it: an explicit breaker is a deliberate scope decision, and this switch does not overrule it. That breaker then acts as a prototype - each key gets one of its own with those settings, because sharing a single breaker's state across every key would defeat the point of keying.

The retry budget follows the same rule. RetryBudget.Automatic - the shipped default - means "no scope decision was made", so each key gets its own. A shared budget is a deliberate decision and is left as is.

Where the state lives

The guards live on the interceptor, and the interceptor is registered at channel scope. One channel gets one set of guards, for the life of the client.

That matters more than it sounds. An interceptor built per call hands every call a fresh breaker that has never seen a failure and a fresh budget that has never seen a deposit - resilience that reads as configured and provides none. NRES005 catches it. AddGrpcResilience() passes the scope explicitly rather than relying on a default, so the registration cannot ship the failure it exists to prevent.

Hold an interceptor you build by hand in a static readonly field or a container singleton, for the same reason.

Read the scopes from a health check

The registration adds every scope's breaker and budget to ResilienceHealthOptions under the client's name, so AddHealthChecks().AddResilience() reports them with no wiring of yours. An operator gets told which dependency opened, rather than that something did.

Released under the MIT License.