Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add antipatterns to fallback strategy #1607

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions docs/strategies/fallback.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,195 @@ new ResiliencePipelineBuilder<UserAvatar>()
| `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. |

## Patterns and Anti-patterns
Throughout the years many people have used Polly in so many different ways. Some reoccuring patterns are suboptimal. So, this section shows the donts and dos.
peter-csala marked this conversation as resolved.
Show resolved Hide resolved

### 1 - Using fallback to replace thrown exception

❌ DON'T

Throw custom exception from the `OnFallback`

<!-- snippet: fallback-anti-pattern-1 -->
```cs
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();
```
<!-- endSnippet -->

**Reasoning**:
- Throwing an exception in an user-defined delegate is never a good idea
peter-csala marked this conversation as resolved.
Show resolved Hide resolved
- It is breaking the normal control flow

✅ DO

Use `ExecuteOutcomeAsync` and then assess `Exception`

<!-- snippet: fallback-pattern-1 -->
```cs
var outcome = await WhateverPolicy.ExecuteOutcomeAsync(Action, context, "state");
if (outcome.Exception is HttpRequestException hre)
{
throw new CustomNetworkException("Replace thrown exception", hre);
}
```
<!-- endSnippet -->


**Reasoning**:
peter-csala marked this conversation as resolved.
Show resolved Hide resolved
- This approach executes the strategy/pipeline without "jumping out from the normal flow"
- If you find yourself in a situation that you write this Exception "remapping" logic again and again
- then mark the to-be-decorated method as `private`
- and expose the "remapping" logic as `public`

<!-- snippet: fallback-pattern-1-ext -->
```cs
public static async ValueTask<HttpResponseMessage> Action()
{
var context = ResilienceContextPool.Shared.Get();
var outcome = await WhateverPolicy.ExecuteOutcomeAsync<HttpResponseMessage, string>(
async (ctx, state) =>
{
var result = await ActionCore();
return Outcome.FromResult(result);
}, context, "state");

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

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

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

### 2 - Using retry to perform fallback

Lets suppose you have a primary and a secondary endpoints. If primary fails then you want to call the secondary.

❌ DON'T

Use retry to perform fallback

<!-- snippet: fallback-anti-pattern-2 -->
```cs
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;
```
<!-- endSnippet -->

**Reasoning**:
- Retry policy by default executes the exact same operation at most `n` times
peter-csala marked this conversation as resolved.
Show resolved Hide resolved
- where `n` equals to the initial attempt + `MaxRetryAttempts`
- So, in this particular case this means __2__
- Here the fallback is produced as a side-effect rather than as a substitute

✅ DO

Use fallback to call secondary

<!-- snippet: fallback-pattern-2 -->
```cs
var fallback = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddFallback(new()
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(res => res.StatusCode == HttpStatusCode.RequestTimeout),
OnFallback = async args => await CallSecondary(args.Context.CancellationToken)
})
.Build();

return await fallback.ExecuteAsync(CallPrimary, CancellationToken.None);
```
<!-- endSnippet -->

**Reasoning**:
- The to-be-decorated code is executed only once
- The fallback value will be returned without any extra code (no need for `Context` or `ExecuteOutcomeAsync`)

### 3 - Nesting `ExecuteAsync` calls

There are many ways to combine multiple strategies together. One of the least desired one is the `Execute` hell.

**NOTE:** _This is not strictly related to Fallback but we have seen it many times when Fallback was the most outer._
peter-csala marked this conversation as resolved.
Show resolved Hide resolved

❌ DON'T

Nest `ExecuteAsync` calls

<!-- snippet: fallback-anti-pattern-3 -->
```cs
var result = await fallback.ExecuteAsync(async (CancellationToken outerCT) =>
{
return await timeout.ExecuteAsync(async (CancellationToken innerCT) =>
{
return await CallExternalSystem(innerCT);
}, outerCT);
}, CancellationToken.None);

return result;
```
<!-- endSnippet -->

**Reasoning**:
- This is the same as javascript's callback hell or pyramid of doom
- It is pretty easy to refer to the wrong `CancellationToken` parameter

✅ DO
Use `ResiliencePipelineBuilder` to chain them

<!-- snippet: fallback-pattern-3 -->
```cs
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddPipeline(timeout)
.AddPipeline(fallback)
.Build();

return await pipeline.ExecuteAsync(CallExternalSystem, CancellationToken.None);
```
<!-- endSnippet -->

**Reasoning**:
- Here we are relying Polly provided escalation mechanism rather than building our own via nesting
- The `CancellationToken`s are propagated between the policies automatically on your behalf
173 changes: 172 additions & 1 deletion src/Snippets/Docs/Fallback.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Polly.Fallback;
using System.Net;
using System.Net.Http;
using Polly.Fallback;
using Snippets.Docs.Utils;

namespace Snippets.Docs;
Expand Down Expand Up @@ -61,4 +63,173 @@ public class UserAvatar

public static UserAvatar GetRandomAvatar() => new();
}

private class CustomNetworkException : Exception
{
public CustomNetworkException()
{
}

public CustomNetworkException(string message)
: base(message)
{
}

public CustomNetworkException(string message, Exception innerException)
: base(message, innerException)
{
}
}

public static void AntiPattern_1()
{
#region fallback-anti-pattern-1

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();

#endregion
}

private static readonly ResiliencePipeline<HttpResponseMessage> WhateverPolicy = ResiliencePipeline<HttpResponseMessage>.Empty;
peter-csala marked this conversation as resolved.
Show resolved Hide resolved
private static ValueTask<Outcome<HttpResponseMessage>> Action(ResilienceContext context, string state) => Outcome.FromResultAsValueTask(new HttpResponseMessage());
public static async Task Pattern_1()
{
var context = ResilienceContextPool.Shared.Get();
#region fallback-pattern-1

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

ResilienceContextPool.Shared.Return(context);
}

#region fallback-pattern-1-ext
public static async ValueTask<HttpResponseMessage> Action()
{
var context = ResilienceContextPool.Shared.Get();
var outcome = await WhateverPolicy.ExecuteOutcomeAsync<HttpResponseMessage, string>(
async (ctx, state) =>
{
var result = await ActionCore();
return Outcome.FromResult(result);
}, context, "state");

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

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

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

private static ValueTask<HttpResponseMessage> CallPrimary(CancellationToken ct) => ValueTask.FromResult(new HttpResponseMessage());
private static ValueTask<HttpResponseMessage> CallSecondary(CancellationToken ct) => ValueTask.FromResult(new HttpResponseMessage());
public static async Task<HttpResponseMessage?> AntiPattern_2()
{
var fallbackKey = new ResiliencePropertyKey<HttpResponseMessage?>("fallback_result");

#region fallback-anti-pattern-2

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;

#endregion
}

public static async ValueTask<HttpResponseMessage?> Pattern_2()
{
#region fallback-pattern-2

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

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

#endregion
}

private static ValueTask<HttpResponseMessage> CallExternalSystem(CancellationToken ct) => ValueTask.FromResult(new HttpResponseMessage());
public static async ValueTask<HttpResponseMessage?> Anti_Pattern_3()
{
var timeout = ResiliencePipeline<HttpResponseMessage>.Empty;
var fallback = ResiliencePipeline<HttpResponseMessage>.Empty;

#region fallback-anti-pattern-3
var result = await fallback.ExecuteAsync(async (CancellationToken outerCT) =>
{
return await timeout.ExecuteAsync(async (CancellationToken innerCT) =>
{
return await CallExternalSystem(innerCT);
}, outerCT);
}, CancellationToken.None);

return result;
#endregion
}

public static async ValueTask<HttpResponseMessage?> Pattern_3()
{
var timeout = ResiliencePipeline<HttpResponseMessage>.Empty;
var fallback = ResiliencePipeline<HttpResponseMessage>.Empty;

#region fallback-pattern-3
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddPipeline(timeout)
.AddPipeline(fallback)
.Build();

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