Hedging
Race parallel attempts against tail latency: if the first attempt hasn't answered within the hedge delay, fire a second one. Fastest success wins; losers are cancelled.
See the exceptions reference for how failures and cancellation surface.
Unlike a retry, hedging doesn't wait for the first attempt to fail — it launches a backup while the original is still running. The pattern goes by several names: backup requests (Google's Tail at Scale calls them hedged requests), speculative retry (Cassandra), or speculative execution. Same idea everywhere: spend a little duplicate work to cut p99 latency.
// Fire a second attempt if the first hasn't answered within 100ms.
var fixedHedge = Shield.For<HttpResponseMessage>()
.Hedge(maxHedgedAttempts: 1, delay: TimeSpan.FromMilliseconds(100));
var configuredHedge = Shield.For<HttpResponseMessage>().Hedge(o =>
{
o.MaxHedgedAttempts = 1; // default 1 (plus the original attempt)
o.Delay = TimeSpan.FromSeconds(1); // default 1s
o.OnHedge = e =>
{
logger.LogInformation("Hedge attempt {AttemptNumber}", e.AttemptNumber);
return default;
};
});
Options
API reference: HedgeOptions and HedgeOptions<T>.
| Option | Default | What it does |
|---|---|---|
MaxHedgedAttempts | 1 | Maximum additional attempts after the original |
Delay | 1s | Wait before launching the next attempt (see special values below) |
DelayGenerator | — | Awaited selector returning ValueTask<TimeSpan>: a delay for each pending hedge from its attempt number, context, and elapsed execution time |
OnHedge | — | Awaited callback when a hedge launches, before the attempt starts — e.AttemptNumber is zero-based, so 1 = first hedge after the initial attempt; typed shields also expose the latest handled Outcome<T> |
ActionGenerator | — | Select a different operation for each additional attempt; null uses the original |
HandlesException | — | Local exception predicate; replaces the ambient clause for this hedge |
HandlesResult (HedgeOptions<T>) | — | Local result predicate on Shield<T>; replaces the ambient clause together with HandlesException |
Invalid option values throw KevlarConfigurationException
and identify the options type, property, and offending value.
On Shield<T>, OnHedge receives HedgeEvent<T>. Its nullable Outcome contains the handled
result or exception that triggered an immediate hedge. It is null when elapsed delay launches a
hedge while earlier attempts remain pending. Untyped shields continue to receive HedgeEvent.
Selecting another target
Set ActionGenerator to send a hedge to a different replica without boxing the result.
The generator runs after both hedge callbacks and receives the isolated attempt context plus the
latest handled outcome, when one is available. OriginalAction includes strategies nested inside
the hedge, so returning it preserves the inner pipeline. Returning another operation replaces that
inner pipeline for that attempt. Typed and untyped hedge options both accept the generator delegate
directly; typed options use HedgeActionGeneratorEvent<TResult> and ValueTask<TResult>, while
untyped options use their non-generic, void-returning counterparts.
var replicas = new Func<CancellationToken, ValueTask<string>>[]
{
static _ => new ValueTask<string>("primary"),
static _ => new ValueTask<string>("secondary"),
static _ => new ValueTask<string>("tertiary"),
};
var shield = Shield.For<string>().Hedge(o =>
{
o.MaxHedgedAttempts = replicas.Length - 1;
o.Delay = TimeSpan.FromMilliseconds(100);
o.ActionGenerator = hedge =>
ct => replicas[hedge.AttemptNumber](ct);
});
// The original callback is attempt 0; generated callbacks are attempts 1 and 2.
var response = await shield.ExecuteAsync(ct => replicas[0](ct));
Configure result-returning generators after Shield.For<TResult>(). An action generator configured
on an untyped shield is void-specific, so lifting that shield to a result type fails while the typed
shield is built.
Adaptive delays
Use a delay generator when each execution or hedge needs different timing. Generator delays are
relative to the previous hedge launch, so 100ms followed by 300ms launches attempts 2 and 3 at
approximately 100ms and 400ms. A handled failure still launches the next attempt immediately.
var hedgeDelay = new KevlarKey<TimeSpan>("hedge-delay");
var adaptiveHedge = Shield.For<int>().Hedge(options =>
{
options.MaxHedgedAttempts = 2;
options.DelayGenerator = hedge => new(
hedge.Context.Properties.GetOrDefault(hedgeDelay, TimeSpan.FromMilliseconds(100)));
});
var adaptiveResult = await adaptiveHedge.ExecuteWithContextAsync(
TimeSpan.FromMilliseconds(75),
(delay, properties) => properties.Set(hedgeDelay, delay),
static (_, _) => new ValueTask<int>(42));
HedgeDelayEvent.AttemptNumber is the zero-based execution number (1 = first hedge), and Elapsed
is measured from the primary attempt's start through the shield's TimeProvider. Generated
negative delays become zero, values above the runtime timer limit are clamped, and the same special
zero/infinite meanings apply. Generator exceptions fail the execution and cancel in-flight attempts.
Special delay values
TimeSpan.Zero— race all attempts at once.- Any negative fixed
HedgeOptions.Delay, includingTimeout.InfiniteTimeSpan— never hedge on latency; launch the next attempt only when the previous one fails. - Any delay: a handled failure always launches the next attempt immediately, without waiting out the rest of the delay.
Zero delay removes timer staggering and starts scheduling the primary plus all
MaxHedgedAttempts, even when the primary delegate completes synchronously. Each additional
delegate still waits for its callbacks and cancellation checks, so a yielding callback can delay or
prevent its start. If no attempt produces an acceptable outcome, the final outcome processed by the
coordinator surfaces; do not rely on chronological completion order when several are already done.
The rules
Multiple invocations of your delegate may be in flight at once — it must be safe to invoke concurrently. This is also why hedging requires async execution: synchronous Execute throws NotSupportedException.
Shield needs an idempotent actionAn untyped Shield can judge attempts only by their exceptions, so every attempt it launches runs
to completion against the real dependency. A losing hedge still did its work: duplicate writes,
charges, or sends are observable unless the action is idempotent. Prefer Shield.For<T>(), where a
result clause decides which attempt is acceptable — or
confirm the action is safe to repeat. The KEV006 analyzer
flags untyped Hedge(...) for exactly this reason.
- Losing attempts are cancelled through their token (use the token you're handed!).
- Once an execution races or launches an additional attempt, every completed primary or additional
attempt emits a
hedge_attempttelemetry event with its zero-based attempt number, success/exception/cancellation outcome, winner flag, and elapsed time. Thekevlar.hedge_attemptscounter classifies completions aswon,lost,cancelled, orfailed. Structured logging writes ordinary losers at Debug and failed losers at Information. - Caller cancellation prevents any later hedge delegate from running, even when it races a completed stagger delay or occurs inside
DelayGeneratororOnHedge. A cancellation already observable at the launch boundary suppresses callbacks. - Launch ordering is the awaited
DelayGenerator(while the previous attempt is pending), then awaitedOnHedge, action generation, metrics, then the selected operation. Suppression requested by another active attempt during delay selection, notification, or action generation stops the operation before invocation. Under the shared callback-failure contract, hook failures are reported throughKevlarDiagnostics.OnCallbackErrorand do not suppress the launch. Generator failures preserve their exception identity, cancel in-flight attempts, and are not counted as launched hedges. - Each attempt gets a forked context —
Propertiesare copied at launch time, so attempts don't see each other's writes. - Callback contexts are pooled. Do not retain them after the returned
ValueTaskcompletes; a generated action's isolated context remains valid until that attempt completes. - Callbacks and generators run without a strategy lock. They may re-enter the same shield, and concurrent shield executions may invoke them concurrently; keep captured state thread-safe.
- Losing operations are cancelled first. After each operation completes, any non-selected result
is disposed and its isolated context is returned to the pool. This also covers handled results
superseded by a later attempt and non-selected
OriginalActionresults produced inside anActionGenerator. Kevlar prefersIAsyncDisposablewhen both disposal interfaces are present; the selected result remains caller-owned. Disposal failures are reported throughKevlarDiagnostics.OnCallbackErrorasCallbackErrorKind.ResultDisposalwithout changing the selected outcome. - What counts as a failure is the ambient handling clause, unless the
options set
HandlesExceptionorHandlesResultas a per-strategy override.
When to hedge (and when not to)
Hedging trades extra load for lower tail latency. It shines for:
- Idempotent reads against replicated backends — the second replica probably isn't having the same GC pause.
- Latency SLOs where p99 matters more than average cost.
Avoid it for writes that aren't idempotent (you may execute them twice!) and for dependencies that are slow because they're overloaded — hedging feeds the overload. Over HTTP, Kevlar.Extensions.Http enforces the first rule: POST, PATCH, and custom methods stay single-attempt until explicitly opted in per request with AllowReplay(), handler-wide with AllowUnsafeMethodReplay, or through a RequestFactory, typically alongside an idempotency key — see method safety. Pair it with a circuit breaker or rate limit when in doubt:
var shield = Shield.For<HttpResponseMessage>()
.Timeout(TimeSpan.FromSeconds(2))
.Hedge(maxHedgedAttempts: 1, delay: TimeSpan.FromMilliseconds(100))
.CircuitBreaker(o => o.FailureRatio = 0.5);