Table of Contents

Fallback resilience strategy

About


Usage

// A fallback/substitute value if an operation fails.
var optionsSubstitute = new FallbackStrategyOptions<UserAvatar>
{
    ShouldHandle = new PredicateBuilder<UserAvatar>()
        .Handle<SomeExceptionType>()
        .HandleResult(r => r is null),
    FallbackAction = static args => Outcome.FromResultAsValueTask(UserAvatar.Blank)
};

// Use a dynamically generated value if an operation fails.
var optionsFallbackAction = new FallbackStrategyOptions<UserAvatar>
{
    ShouldHandle = new PredicateBuilder<UserAvatar>()
        .Handle<SomeExceptionType>()
        .HandleResult(r => r is null),
    FallbackAction = static args =>
    {
        var avatar = UserAvatar.GetRandomAvatar();
        return Outcome.FromResultAsValueTask(avatar);
    }
};

// Use a default or dynamically generated value, and execute an additional action if the fallback is triggered.
var optionsOnFallback = new FallbackStrategyOptions<UserAvatar>
{
    ShouldHandle = new PredicateBuilder<UserAvatar>()
        .Handle<SomeExceptionType>()
        .HandleResult(r => r is null),
    FallbackAction = static args =>
    {
        var avatar = UserAvatar.GetRandomAvatar();
        return Outcome.FromResultAsValueTask(UserAvatar.Blank);
    },
    OnFallback = static args =>
    {
        // Add extra logic to be executed when the fallback is triggered, such as logging.
        return default; // Returns an empty ValueTask
    }
};

// Add a fallback strategy with a FallbackStrategyOptions<TResult> instance to the pipeline
new ResiliencePipelineBuilder<UserAvatar>().AddFallback(optionsOnFallback);

Defaults

Property Default Value Description
ShouldHandle Predicate that handles all exceptions except OperationCanceledException. Predicate that determines what results and exceptions are handled by the fallback strategy.
FallbackAction Null, Required Fallback action to be executed.
OnFallback null Event that is raised when fallback happens.

Diagrams

Happy path sequence diagram

sequenceDiagram
    actor C as Caller
    participant P as Pipeline
    participant F as Fallback
    participant D as DecoratedUserCallback

    C->>P: Calls ExecuteAsync
    P->>F: Calls ExecuteCore
    F->>+D: Invokes
    D->>-F: Returns result
    F->>P: Returns result
    P->>C: Returns result

Unhappy path sequence diagram

sequenceDiagram
    actor C as Caller
    participant P as Pipeline
    participant F as Fallback
    participant FA as FallbackAction
    participant D as DecoratedUserCallback

    C->>P: Calls ExecuteAsync
    P->>F: Calls ExecuteCore
    F->>+D: Invokes
    D->>-F: Fails
    F->>+FA: Invokes
    FA-->>FA: Calculates substitute result
    FA->>-F: Returns <br/>substituted result
    F->>P: Returns <br/>substituted result
    P->>C: Returns <br/>substituted result

Patterns

Fallback after retries

When designing resilient systems, a common pattern is to use a fallback after multiple failed retry attempts. This approach is especially relevant when a fallback strategy can provide a sensible default value.

// Define a common predicates re-used by both fallback and retries
var predicateBuilder = new PredicateBuilder<HttpResponseMessage>()
    .Handle<HttpRequestException>()
    .HandleResult(r => r.StatusCode == HttpStatusCode.InternalServerError);

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddFallback(new()
    {
        ShouldHandle = predicateBuilder,
        FallbackAction = args =>
        {
            // Try to resolve the fallback response
            HttpResponseMessage fallbackResponse = ResolveFallbackResponse(args.Outcome);

            return Outcome.FromResultAsValueTask(fallbackResponse);
        }
    })
    .AddRetry(new()
    {
        ShouldHandle = predicateBuilder,
        MaxRetryAttempts = 3,
    })
    .Build();

// Demonstrative execution that always produces invalid result
pipeline.Execute(() => new HttpResponseMessage(HttpStatusCode.InternalServerError));

Here's a breakdown of the behavior when the callback produces either an HttpStatusCode.InternalServerError or an HttpRequestException:

  • The fallback strategy initiates by executing the provided callback, then immediately passes the execution to the retry strategy.
  • The retry strategy starts execution, makes 3 retry attempts and yields the outcome that represents an error.
  • The fallback strategy resumes execution, assesses the outcome generated by the callback, and if necessary, supplies the fallback value.
  • The fallback strategy completes its execution.
Note

The preceding example also demonstrates how to re-use ResiliencePipelineBuilder<HttpResponseMessage> across multiple strategies.

Anti-patterns

Over the years, many developers have used Polly in various ways. Some of these recurring patterns may not be ideal. The sections below highlight anti-patterns to avoid.

Using fallback to replace thrown exception

❌ DON'T

Throw custom exceptions from the OnFallback delegate:

var fallback = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddFallback(new()
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>().Handle<HttpRequestException>(),
        FallbackAction = args => Outcome.FromResultAsValueTask(new HttpResponseMessage()),
        OnFallback = args => throw new CustomNetworkException("Replace thrown exception", args.Outcome.Exception!)
    })
    .Build();

Reasoning:

Throwing an exception from a user-defined delegate can disrupt the normal control flow.

✅ DO

Use ExecuteOutcomeAsync and then evaluate the Exception:

var outcome = await WhateverPipeline.ExecuteOutcomeAsync(Action, context, "state");
if (outcome.Exception is HttpRequestException requestException)
{
    throw new CustomNetworkException("Replace thrown exception", requestException);
}

Reasoning:

This method lets you execute the strategy or pipeline smoothly, without unexpected interruptions. If you repeatedly find yourself writing this exception "remapping" logic, consider marking the method you wish to decorate as private and expose the "remapping" logic publicly.

public static async ValueTask<HttpResponseMessage> Action()
{
    var context = ResilienceContextPool.Shared.Get();
    var outcome = await WhateverPipeline.ExecuteOutcomeAsync<HttpResponseMessage, string>(
        async (ctx, state) =>
        {
            var result = await ActionCore();
            return Outcome.FromResult(result);
        }, context, "state");

    if (outcome.Exception is HttpRequestException requestException)
    {
        throw new CustomNetworkException("Replace thrown exception", requestException);
    }

    ResilienceContextPool.Shared.Return(context);
    return outcome.Result!;
}

private static ValueTask<HttpResponseMessage> ActionCore()
{
    // The core logic
    return ValueTask.FromResult(new HttpResponseMessage());
}

Using retry for fallback

Suppose you have a primary and a secondary endpoint. If the primary fails, you want to call the secondary.

❌ DON'T

Use retry for fallback:

var fallback = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new()
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .HandleResult(res => res.StatusCode == HttpStatusCode.RequestTimeout),
        MaxRetryAttempts = 1,
        OnRetry = async args =>
        {
            args.Context.Properties.Set(fallbackKey, await CallSecondary(args.Context.CancellationToken));
        }
    })
    .Build();

var context = ResilienceContextPool.Shared.Get();
var outcome = await fallback.ExecuteOutcomeAsync<HttpResponseMessage, string>(
    async (ctx, state) =>
    {
        var result = await CallPrimary(ctx.CancellationToken);
        return Outcome.FromResult(result);
    }, context, "none");

var result = outcome.Result is not null
    ? outcome.Result
    : context.Properties.GetValue(fallbackKey, default);

ResilienceContextPool.Shared.Return(context);

return result;

Reasoning:

A retry strategy by default executes the same operation up to N times, where N equals the initial attempt plus MaxRetryAttempts. In this case, that means 2 times. Here, the fallback is introduced as a side effect rather than a replacement.

✅ DO

Use fallback to call the secondary:

var fallback = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddFallback(new()
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .HandleResult(res => res.StatusCode == HttpStatusCode.RequestTimeout),
        FallbackAction = async args => Outcome.FromResult(await CallSecondary(args.Context.CancellationToken))
    })
    .Build();

return await fallback.ExecuteAsync(CallPrimary, CancellationToken.None);

Reasoning:

  • The target code is executed only once.
  • The fallback value is returned directly, eliminating the need for additional code like Context or ExecuteOutcomeAsync().

Nesting ExecuteAsync calls

Combining multiple strategies can be achieved in various ways. However, deeply nesting ExecuteAsync calls can lead to what's commonly referred to as Execute Hell.

Note

While this isn't strictly tied to the Fallback mechanism, it's frequently observed when Fallback is the outermost layer.

❌ DON'T

Nest ExecuteAsync calls:

var result = await fallback.ExecuteAsync(async (CancellationToken outerCT) =>
{
    return await timeout.ExecuteAsync(static async (CancellationToken innerCT) =>
    {
        return await CallExternalSystem(innerCT);
    }, outerCT);
}, CancellationToken.None);

return result;

Reasoning:

This is akin to JavaScript's callback hell or the pyramid of doom. It's easy to mistakenly reference the wrong CancellationToken parameter.

✅ DO

Use ResiliencePipelineBuilder to chain strategies:

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddPipeline(timeout)
    .AddPipeline(fallback)
    .Build();

return await pipeline.ExecuteAsync(CallExternalSystem, CancellationToken.None);

Reasoning:

In this approach, we leverage the escalation mechanism provided by Polly rather than creating our own through nesting. CancellationToken values are automatically propagated between the strategies for you.