Skip to main content

Composition

Chain order = execution order​

The first strategy in a chain is the outermost — the same rule as ASP.NET middleware:

var shield = Shield
.Timeout(TimeSpan.FromSeconds(30)) // 1. total budget around everything below
.Retry(3) // 2. retries happen inside that budget
.CircuitBreaker(consecutiveFailures: 5, breakDuration: 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 most r + 1 hedge groups, each containing at most h + 1 attempts (the original plus h hedges). A new group starts only after the previous group is exhausted.
  • Hedge(h).Retry(r) creates at most h + 1 attempts, each with its own retry loop of at most r + 1 invocations.
  • The maximum in either order is (r + 1) × (h + 1), 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.

Explaining effective behavior​

Use GetDescriptor().Explain() from the optional Kevlar.Testing package to inspect the combined behavior without executing the operation, handling predicates, or duration/action generators:

using Kevlar;
using Kevlar.Testing;

var explainedShield = Shield.Timeout(TimeSpan.FromSeconds(30))
.Retry(3, Backoff.None)
.Timeout(TimeSpan.FromSeconds(5))
.Retry(3, Backoff.None);
var explanation = explainedShield.GetDescriptor().Explain();
Console.WriteLine(explanation.MaxAttempts); // 16: (3 + 1) * (3 + 1)
Console.WriteLine(explanation);

Strategies retains outer-to-inner descriptor order. Each StrategyExplanation carries its descriptor and zero-based index. HandlingSource identifies default handling, an inherited ambient clause, strategy-local predicate overrides, or custom handling. HandlingDescription exposes the effective clause text where available; predicate logic remains opaque. Composing shields with Wrap or Compose preserves their sealed handling clauses in this explanation.

MaxAttempts is a theoretical upper bound, not an expected or guaranteed execution count. Actual attempts may be zero and depend on outcomes, cancellation, rejections, and timeouts. Retry and hedge factors multiply using each count plus the original attempt. Fallback delegates, notifications, and arbitrary work performed by user code are outside the count. Inspect AttemptBoundKind before using the nullable bound:

KindMeaning
FiniteA conservative long upper bound is available.
UnboundedA RetryForever sentinel supplies no useful configured finite budget; its implementation counter limit is not treated as an operational budget.
IndeterminateA custom strategy or generated hedge action prevents a reliable bound.
OverflowThe finite product exceeds long.MaxValue; no wrapped or truncated number is returned.

Dynamic retry/hedge delays do not change a fixed attempt-count bound. Dynamic timeout durations are reported without evaluating their generators. Time budgets never reduce the theoretical count: operation duration and cancellation cooperation are unknown.

For timeouts, TimeoutScope.Execution means one budget around the downstream suffix. TimeoutScope.Attempt means a fresh budget for each enclosing retry/hedge attempt; EnclosingAttemptStrategies lists those ancestor indexes. In the example, timeout index 2 repeats within retry index 1 and covers the whole inner retry group, not each innermost operation. An enclosing custom strategy makes the scope Indeterminate. All timeout budgets use cooperative cancellation and do not guarantee wall-clock completion for operations that ignore cancellation.

The API stays in Kevlar.Testing to reuse descriptors and avoid expanding the core runtime dependency surface. It can be used for on-demand production diagnostics; inspection and formatting allocate and belong outside request hot paths. GetDescriptor() takes the current publication of a reloading shield and omits transparent infrastructure decorators by default. Including a transparent decorator as a custom descriptor can make the bound indeterminate. Text output is diagnostic; use the structured properties for application logic.

Merging independent shields​

Use Wrap to put one shield around another, or Compose to stack several:

var breaker = Shield.CircuitBreaker(consecutiveFailures: 5, breakDuration: 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

The two are the same operation, so there is no semantic difference to hunt for: outer.Wrap(inner) and Shield.Compose(outer, inner) produce the same strategy order, keep the same first non-null name and TimeProvider, and seal the ambient clause identically. Write Wrap when one shield reads as the scope around another, and Compose when several stack as peers.

Result-aware shields compose the same way through Shield<T>.Compose, with the same metadata and clause-sealing rules:

var typedTimeout = Shield.For<HttpResponseMessage>().Timeout(TimeSpan.FromSeconds(5));
var typedRetry = Shield.For<HttpResponseMessage>().Retry(3);

var typed = Shield<HttpResponseMessage>.Compose(typedTimeout, typedRetry); // first = outermost
What survives a merge

Wrap and Compose carry the strategies (with their state) forward. Both keep the first non-null name and TimeProvider from outer to inner. Ambient handling clauses stop at the composition boundary, so reactive strategies appended to the merged shield use default handling unless you declare a new clause.

Strategies already inside each input keep the handling rules they were built with. Composition seals only the ambient clause that would otherwise affect later additions; it does not change existing pipeline behavior. In outer.Wrap(inner), outer metadata still 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.
  • Wrap and Compose don'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 Strategy can opt into the same protection by overriding IsDuplicateReferenceUnsafe and returning true.

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.

When one downstream dependency needs independent strategy state per endpoint, tenant, or other key, use a partitioned shield to create and retain one shield instance per partition.

// 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 a fluent chain​

The ambient clause rule

A handling clause applies to the strategy it is attached to and to every reactive strategy chained after it, until a new clause replaces it, WithDefaultHandling() resets it, or Wrap/Compose seals it at a composition boundary.

The circuit breaker below never declares a clause of its own. It inherits the one written at the top of the chain, so only HttpRequestException counts toward tripping it:

var shield = Shield
.When<HttpRequestException>()
.Retry(3) // retries HttpRequestException
.CircuitBreaker(consecutiveFailures: 5, breakDuration: TimeSpan.FromSeconds(30));
// ↑ the breaker counts HttpRequestException failures only

Proactive strategies — timeout, rate limit, concurrency limit — never consult a clause, but they do not end it either: the clause stays ambient for the next reactive strategy. A clause that reaches no reactive strategy at all is dead, and the built-in analyzer reports it as KEV007.

Writing a new clause mid-chain replaces the previous one from that point on:

var shield = Shield
.When<HttpRequestException>()
.Retry(3) // retries HttpRequestException
.CircuitBreaker(consecutiveFailures: 5, breakDuration: breakDuration) // breaker also counts HttpRequestException
.When<TimeoutExceededException>()
.Fallback(static _ => ValueTask.CompletedTask); // fallback reacts to TimeoutExceededException only

Wrap and Compose are scope boundaries. A reactive strategy added afterwards uses its default handling unless the new expression declares a clause locally. Retry, circuit breaker, and hedge exclude cancellation, Kevlar's fail-fast rejections, and fatal runtime failures; fallback also handles fail-fast rejections:

var apiDefaults = Shield.When<HttpRequestException>().Retry(3);

var shield = Shield.Timeout(TimeSpan.FromSeconds(30))
.Wrap(apiDefaults)
.When<HttpRequestException>()
.CircuitBreaker(
consecutiveFailures: 5,
breakDuration: TimeSpan.FromSeconds(30));

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:

var invalid = Shield.For<int>().Retry(3).FallbackTo(-1);
// InvalidOperationException: … makes Retry(3, …) unreachable.
// Chain the Fallback first (the first strategy is the outermost) …

var valid = Shield.For<int>().FallbackTo(-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(consecutiveFailures: 5, breakDuration: TimeSpan.FromSeconds(30))
.WithName("github");

logger.LogInformation("using {Shield}", shield);
// github: Timeout(30s) → Retry(3, exponential 250ms ×2, equal jitter, cap 30s) → CircuitBreaker(5 consecutive, break 30s)

Log it at startup and code review becomes "read the log line".