Fallback resilience strategy
About
- Option(s):
- Extension(s):
AddFallback
- Exception(s): -
The fallback reactive resilience strategy provides a substitute if the execution of the callback fails. Failure can be either an Exception
or a result object indicating unsuccessful processing. Typically this strategy is used as a last resort, meaning that if all other strategies failed to overcome the transient failure you could still provide a fallback value to the caller.
Note
In this document the fallback, substitute, and surrogate terms are used interchangeably.
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);
},
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 |
Any exceptions other than OperationCanceledException . |
Defines a predicate to determine what results and/or exceptions are handled by the fallback strategy. |
FallbackAction |
Null , Required |
This delegate allows you to dynamically calculate the surrogate value by utilizing information that is only available at runtime (like the outcome). |
OnFallback |
null |
If provided then it will be invoked before the strategy calculates the fallback value. |
Telemetry
The fallback strategy reports the following telemetry events:
Event Name | Event Severity | When? |
---|---|---|
OnFallback |
Warning |
Just before the strategy calls the OnFallback delegate |
Here are some sample events:
Resilience event occurred. EventName: 'OnFallback', Source: 'MyPipeline/MyPipelineInstance/MyFallbackStrategy', Operation Key: 'MyFallbackGuardedOperation', Result: '-1'
Resilience event occurred. EventName: 'OnFallback', Source: '(null)/(null)/Fallback', Operation Key: '', Result: 'Exception of type 'CustomException' was thrown.'
CustomException: Exception of type 'CustomException' was thrown.
at Program.<>c.<Main>b__0_3(ResilienceContext ctx)
...
at Polly.ResiliencePipeline.<>c__8`1.<<ExecuteAsync>b__8_0>d.MoveNext() in /_/src/Polly.Core/ResiliencePipeline.AsyncT.cs:line 95
Note
Please note that the OnFallback
telemetry event will be reported only if the fallback strategy provides a surrogate value.
So, if the callback either returns an acceptable result or throws an unhandled exception then there will be no telemetry emitted.
Also remember that the Result
will be always populated for the OnFallback
telemetry event.
For further information please check out the telemetry page.
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()),
=> 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,
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
orExecuteOutcomeAsync()
.
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.