Skip to main content

Custom Strategies

Everything in Kevlar is a Strategy — middleware over an Outcome<T> pipeline. Retry, circuit breaker, timeout: all of them are implemented on the same surface you extend.

A logging strategy

public sealed class LoggingStrategy(ILogger logger) : Strategy
{
public override async ValueTask<Outcome<T>> ExecuteAsync<T, TState>(
Continuation<T, TState> next, KevlarContext context)
{
var start = context.TimeProvider.GetTimestamp();
var outcome = await next.InvokeAsync(context);
logger.LogInformation("{Shield} took {Elapsed}", context.ShieldName,
context.TimeProvider.GetElapsedTime(start));
return outcome;
}
}

var shield = Shield.Use(new LoggingStrategy(logger)).Retry(3);

Use slots your strategy into the chain at that position — here, outside the retries, so it logs total elapsed time across all attempts. Put it after Retry to log each attempt instead.

Override Describe() so shield.ToString() names your strategy meaningfully in pipeline descriptions:

public override string Describe() => "Logging";

protected override bool InvokesContinuationAtMostOnce => true;

The contract

Your strategy receives:

  • next — the rest of the pipeline (inner strategies, then the user's delegate). Invoke it with next.InvokeAsync(context).
  • context — the KevlarContext for this execution.

And returns an Outcome<T>: success-with-result or failure-with-exception, as a struct.

next.InvokeAsync(context) preserves the caller-supplied state and the same context, including its current name, time provider, cancellation token, and properties. Synchronous throws and asynchronous faults from inner strategies or the user's delegate are normalized to failure outcomes, so a valid continuation does not throw. A default, uninitialized Continuation<T, TState> returns an InvalidOperationException outcome.

The power is in how many times you call next:

Calls to nextYou've built aExamples in the box
zeroshort-circuitcircuit breaker (open), rate limit, concurrency limit rejection
onedecoratortimeout, fallback, logging, metrics
manyrepeaterretry, hedging

Override InvokesContinuationAtMostOnce with true only when every execution path calls next zero or one time. This lets consumers such as the gRPC interceptors safely expose response headers before the response completes. Keep the conservative false default for retry, hedging, loops, or any strategy that may call next more than once.

Failures are outcomes, not throws

Strategies return failures as Outcome<T> values rather than throwing, so outer strategies can react to them cheaply:

public override async ValueTask<Outcome<T>> ExecuteAsync<T, TState>(
Continuation<T, TState> next, KevlarContext context)
{
var outcome = await next.InvokeAsync(context);

if (!outcome.IsSuccess)
{
// inspect outcome.Exception, decide what to do:
// return outcome unchanged, replace it, or try next again
}

return outcome;
}

The exception is only thrown once — at the pipeline boundary, back in the caller's frame, with its original stack trace intact.

Consume handling clauses

Reactive custom strategies should use the shield's active handling clause instead of hard-coding exception filters. Pass a factory to Use; Kevlar invokes it once and supplies a HandlingClause that wraps the current When/Or clause, or HandlingClause.Default when none is active:

public sealed class RetryOnceStrategy(HandlingClause handling) : Strategy
{
protected override HandlingClause? Handling => handling;

public override async ValueTask<Outcome<T>> ExecuteAsync<T, TState>(
Continuation<T, TState> next, KevlarContext context)
{
var strategyIndex = context.StrategyIndex;
var outcome = await next.InvokeAsync(context);
return handling.ShouldHandle(
in outcome,
context,
attemptNumber: 0,
strategyIndex: strategyIndex)
? await next.InvokeAsync(context)
: outcome;
}
}

var shield = Shield
.When<HttpRequestException>()
.Use(clause => new RetryOnceStrategy(clause));

ShouldHandle works with exception and typed-result outcomes. Pass the active context, attempt, and the strategy index captured before invoking next to support context-aware clauses. The default handles ordinary exceptions, excluding cancellation, Kevlar's fail-fast rejections, and fatal runtime failures. The existing Use(Strategy) overload remains the simpler choice for proactive strategies that do not inspect failures.

Override Strategy.Handling when retaining the supplied clause, as above. This declaration lets Kevlar's unreachable-fallback validation and Kevlar.Testing custom strategy descriptors see the strategy's handling. A strategy with intentionally local rules may ignore the factory argument and implement those rules itself.

Context properties

KevlarContext.Properties is isolated per execution. A KevlarKey<T> is identified by both its case-sensitive name and T: keys with the same name and different value types do not collide, while new key instances with the same name and type address the same value. Empty names are valid. Stored null is present and distinct from a missing key, so TryGet returns true with a null value and GetOrDefault does not substitute its fallback.

Callers can seed this bag with ExecuteWithContextAsync or ExecuteWithContext. The initializer runs before the outermost strategy, retries reuse the logical context, and hedged attempts receive detached property snapshots. See Executing.

KevlarContext

The context flows through the whole pipeline:

  • context.ShieldName — set via WithName, for logs and metrics.
  • context.TimeProvideralways use this instead of DateTime/Stopwatch/Task.Delay, so your strategy stays testable with FakeTimeProvider like the built-ins.
  • context.CancellationToken — the current token. Strategies such as timeouts replace this for the layers beneath them — which is why delegates must use the token they're handed rather than a captured one.
  • context.IsSynchronoustrue under Execute; branch on it if your strategy would otherwise block or break a sync caller (hedging throws for sync callers this way).
  • context.StrategyIndex — the current strategy's zero-based pipeline position. Nested execution restores the outer index before its strategy resumes, and hedge forks preserve the inner position.
  • context.Properties — a typed property bag: Set(key, value), TryGet(key, out value), GetOrDefault(key), keyed by KevlarKey<T>:
static readonly KevlarKey<string> TenantId = new("tenant-id");

context.Properties.Set(TenantId, "acme");
if (context.Properties.TryGet(TenantId, out var tenant)) { /* ... */ }

Contexts are pooled and recycled by the engine — never store one beyond the execution. The continuation also belongs to that execution; invoke it only while ExecuteAsync is running.

Thread safety

One strategy instance is shared by every execution of the shield containing it — and by every shield it's composed into. That's the state-sharing rule working in your favour, but it means your strategy must be thread-safe, like the built-in breakers and limiters.

Stateless strategies can be shared directly. Stateful strategies must synchronize their own mutable fields. Per-execution data belongs in local variables or KevlarContext.Properties, not in strategy instance fields.