Composition
Chain order = execution order
The first strategy in a chain is the outermost — the same rule as ASP.NET middleware:
Shield
.Timeout(TimeSpan.FromSeconds(30)) // 1. total budget around everything below
.Retry(3) // 2. retries happen inside that budget
.CircuitBreaker(5, TimeSpan.FromSeconds(30)) // 3. breaker sees each attempt
.Timeout(TimeSpan.FromSeconds(5)); // 4. each individual attempt gets 5s
Reading top to bottom tells you exactly what happens: the outer timeout caps the whole thing, retries happen inside it, each retried attempt goes through the breaker, and each attempt individually gets 5 seconds.
This is the classic "total timeout outside, attempt timeout inside" pattern — one chain, no nesting.
Retry, hedge, and timeout scopes
Order also defines how retry and hedging multiply work:
Retry(r).Hedge(h)creates at mostr + 1hedge groups, each containing at mosthattempts. A new group starts only after the previous group is exhausted.Hedge(h).Retry(r)creates at mosthhedge attempts, each with its own retry loop of at mostr + 1invocations.- The maximum in either order is
(r + 1) × h, but a winner, unhandled exception, or caller cancellation stops the outer strategy from multiplying more work.
Timeout position follows the same rule. Timeout(t).Hedge(h) is one total budget around every
fork. Hedge(h).Timeout(t) gives each fork an independent budget. Cancelling hedge losers does
not count as their timeout and does not invoke their OnTimeout callback.
Merging independent shields
Use Wrap to put one shield around another, or Compose to stack several:
var breaker = Shield.CircuitBreaker(5, TimeSpan.FromSeconds(30)); // built once — holds the circuit state
var reads = Shield.Retry(3).Wrap(breaker);
var writes = Shield.Timeout(TimeSpan.FromSeconds(5)).Wrap(breaker);
// reads and writes share ONE circuit: failures through either trip both.
var combined = Shield.Compose(timeoutShield, retryShield, breakerShield); // first = outermost
Wrap and Compose carry the strategies (with their state) forward. Both keep the first non-null name and TimeProvider from outer to inner, while the innermost available ambient handling clause stays ambient for further chaining. In outer.Wrap(inner), outer metadata therefore wins when present, and the inner clause wins when present.
The state-sharing rule
Strategy state lives with the shield instance that created it.
That's the whole rule. Consequences:
- Reuse a shield instance across call sites → those call sites share the circuit breaker's state, the rate limiter's token bucket, the concurrency limit's slots.
- Build a new shield (even with identical configuration) → fresh, independent state.
WrapandComposedon't copy state — they reference the wrapped shield, so the sharing above works across merged shields too.- Stateless custom strategy instances may appear more than once in one chain. Kevlar rejects duplicate references to built-in stateful breakers and limiters because nesting the same instance can deadlock or double-count. A stateful custom
Strategycan opt into the same protection by overridingIsDuplicateReferenceUnsafeand returningtrue.
This is deliberate. A circuit breaker that doesn't share state across the call sites hitting the same dependency isn't protecting anything; a rate limiter with per-call-site buckets isn't limiting anything.
// One breaker guarding one downstream dependency, shared by two shaped pipelines:
var downstreamBreaker = Shield.CircuitBreaker(o => { o.FailureRatio = 0.5; o.MinimumThroughput = 20; });
var interactive = Shield.Timeout(TimeSpan.FromSeconds(2)).Wrap(downstreamBreaker);
var background = Shield.Timeout(TimeSpan.FromSeconds(30)).Retry(5).Wrap(downstreamBreaker);
Handling clauses flow down the chain
A handling clause applies to the strategy it precedes and to every reactive strategy chained after it, until you write a new clause:
Shield
.When<HttpRequestException>()
.Retry(3) // retries HttpRequestException
.CircuitBreaker(5, breakDur) // breaker also counts HttpRequestException
.When<TimeoutExceededException>()
.Fallback(...); // fallback reacts to TimeoutExceededException only
Impossible orders fail fast
One ordering is always a bug: a Fallback chained after (inside) a retry, hedge or circuit breaker that shares its handling clause. The fallback recovers every failure before the outer strategy sees one, silently disabling it. Kevlar refuses to build that chain:
Shield.For<int>().Retry(3).Fallback(-1);
// InvalidOperationException: … makes Retry(3, …) unreachable.
// Chain the Fallback first (the first strategy is the outermost) …
Shield.For<int>().Fallback(-1).Retry(3); // ✔ retry runs inside, fallback recovers after it gives up
A fallback with its own narrower clause is still allowed inside — that's a deliberate layered recovery, not a mistake.
See what you built
Every shield describes itself — ToString() prints the pipeline, outermost first, with each strategy's configuration:
var shield = Shield
.Timeout(TimeSpan.FromSeconds(30))
.Retry(3)
.CircuitBreaker(5, TimeSpan.FromSeconds(30))
.WithName("github");
logger.LogInformation("using {Shield}", shield);
// github: Timeout(30s) → Retry(3, exponential 250ms ×2 +jitter ≤30s) → CircuitBreaker(5 consecutive, break 30s)
Log it at startup and code review becomes "read the log line".