Skip to content

Hedging

Hedging starts a second copy of an attempt that is taking longer than almost every other call to the same dependency, and returns whichever answer arrives first. A call that has already outrun the p95 is unlikely to finish quickly, and a fresh attempt often beats it - so the caller sees the p99 of two draws rather than the p99 of one.

Hedging is opt-in. It is off in Resilience.Default, off in Resilience.Http, and off in AddResilience(). Set Hedge to turn it on.

Turn it on

csharp
// The threshold is always a live quantile of recent latency, never a constant. A brownout moves
// the quantile with it, so the fraction of calls that hedge stays at about 1 - Quantile whatever
// the dependency is doing - which is why there is deliberately no Hedge.After(TimeSpan).
var api = Resilience.Http with
{
    Attempts = 3, // at most 3 calls reach the dependency, whatever shape they run in
    Hedge = Hedge.At(quantile: 0.95), // the 2nd may start before the 1st comes back
};

Hedge.At is the only way to configure it. There is deliberately no fixed-delay form: the threshold is always a live quantile of recent latency, which is what makes the feature safe to leave on. See Hedging internals for the argument.

Attempts stays what it says: the total number of calls that reach the dependency, whether they run one after another or at the same time. Attempts = 3 with MaxConcurrent = 2 means at most three wire calls, at most two of them in flight at once. There is one number to reason about, not two multiplied together.

Tune it

csharp
// Hedge.At fills in the rest, so change only what you mean to. The quantile is the load: 0.99
// hedges 1% of calls and shortens a smaller part of the tail than 0.95 does.
var api = Resilience.Http with
{
    Hedge = Hedge.At(quantile: 0.99, maxConcurrent: 3) with
    {
        MinimumSamples = 50, // wait for 50 recent calls before hedging anything
        MinimumDelay = TimeSpan.FromMilliseconds(value: 25), // never hedge sooner than this
        Window = TimeSpan.FromMinutes(value: 1), // how much history the estimate covers
    },
};
PropertyDefaultWhat it means
Quantile-The quantile a hedge fires at, from 0.5 to 1 exclusive. This is also the extra load: 0.95 costs about 5%.
MaxConcurrent2How many attempts may be in flight at once, counting the first.
MinimumSamples20How many recent calls the estimate needs before any hedge fires.
MinimumDelay10 msA floor under the delay, so a dependency with a sub-millisecond p95 does not hedge everything.
Window30 sHow much history the estimate covers.

Pick Quantile by the load you are willing to add. Everything else has a working default, and Hedge.At(0.95) is a complete configuration.

Hold the policy

The latency estimate is private to the policy instance, exactly as the automatic retry budget is. A policy rebuilt on every call never accumulates samples, never reaches MinimumSamples, and never hedges anything.

csharp
public static class Policies
{
    // One instance, for the lifetime of the process. The latency estimate is private to this
    // instance, exactly as the automatic retry budget is, so a `with` expression inside a method
    // would hand every call a policy that has never seen a single latency sample.
    public static readonly Resilience Search = (Resilience.Http with
    {
        Attempts = 3,
        Hedge = Hedge.At(quantile: 0.95),
    }).Validated();
}
csharp
var value = await Policies.Search.RunAsync(attempt => calls.NextAsync(cancellationToken: attempt), cancellationToken: cancellationToken);

Hedge HTTP requests

The HTTP handler needs no hedging configuration of its own.

csharp
// Nothing HTTP-specific is needed. The handler already scopes a policy per host - so each host
// gets its own latency estimate - and already refuses to repeat a POST, which is the same gate a
// hedge has to pass.
services.AddHttpClient<SearchClient>()
    .AddResilience(Resilience.Http with { Hedge = Hedge.At(quantile: 0.95) });

Two things the handler already does are exactly what hedging needs:

  • Per-host scoping. The handler derives one policy per host, so each host gets its own latency estimate. Hedging one host against another host's tail would hedge everything.
  • The idempotency gate. A request the handler will not retry is a request it will not hedge, because a hedge is a concurrent retry. POST and PATCH are not repeatable unless you say so.
csharp
// A hedge is a concurrent retry, so the idempotency key that makes a retried POST safe is what
// makes a hedged one safe. Without this the request is sent exactly once, whatever Hedge says.
using var request = new HttpRequestMessage(method: HttpMethod.Post, requestUri: uri);
request.MarkRepeatable();

Each leg builds its own request from a buffered body, and the responses that lose the race are disposed - so a hedged call leaks no sockets.

One consequence of per-host scoping is worth knowing: the latency estimate belongs to the handler, and IHttpClientFactory rotates handler chains every two minutes by default. A rotated client starts with a cold estimate and hedges nothing until it has seen MinimumSamples calls again - the same way its per-host breakers start closed.

Read what it produces

Three events describe a race, and the attempt log records both legs.

EventWhat it says
HedgeStartedA copy was started. Delay carries the threshold that triggered it, which is the live quantile itself.
HedgeWonThe copy produced the answer, so this call saw the shorter of two draws.
HedgeDiscardedAn attempt was cancelled because a sibling answered first. Duration is how long it had been running.
csharp
var api = Resilience.Http with
{
    Hedge = Hedge.At(quantile: 0.95),
    OnEvent = e =>
    {
        if (e.Kind == CallEventKind.HedgeStarted)
            started++; // e.Delay is the quantile the hedge fired at

        if (e.Kind == CallEventKind.HedgeWon)
            won++; // the copy answered, so this call saw the shorter of two draws
    },
};

In NResilience.Extensions, the same facts arrive as nresilience.hedges tagged started, won and discarded, plus nresilience.hedge.threshold - the adaptive threshold, recorded each time a hedge fires. Watching that number move during an incident is how you tell a brownout from a tail.

The attempt log shows both legs, and a discarded one reads as what it is:

text
2 attempts over 41ms: hedge Ok (1ms), at 40ms, discarded (41ms)

Attempt.IsHedged says the attempt started alongside one already in flight, Attempt.IsDiscarded says it was cancelled because a sibling answered, and Attempt.StartOffset is what makes the overlap visible. A discarded attempt was never classified, so its Verdict carries no information - read the flag instead.

When a hedge does not fire

All five conditions must hold. If any fails, the call waits, exactly as it would without hedging configured:

  1. Hedge is set on the policy.
  2. The call is repeatable. For HTTP, the same gate retry uses.
  3. The circuit breaker is closed. A dependency that is failing does not need a second copy of every slow request.
  4. The estimate has at least MinimumSamples samples. A cold process does not guess a threshold.
  5. The retry budget funds it. Hedges and retries draw on one bucket, so a policy already retrying at its limit stops hedging - a retry is evidence that something failed, and a hedge is only a guess that something is slow.

Go deeper

  • Hedging internals - why an adaptive threshold is safe and a constant one is not, and how the quantile is estimated.
  • Retry budget - the bucket hedges and retries share.
  • Idempotency - what makes a request repeatable.

Released under the MIT License.