Open sandboxFocusImprove this doc

Retry example, step 5: Using Polly

If you had the feeling that we were reinventing the wheel in the previous Retry examples, you were partially right. Libraries like Polly offer more advanced and configurable retry strategies, but even Polly requires some boilerplate code. Wrapping the whole method body in a delegate call and adding proper logging with parameter values entails boilerplate. With a Metalama aspect, we can completely avoid this boilerplate.

Infrastructure code

Before jumping into the implementation, let's consider the architecture and infrastructure code.

We want the Polly strategies to be centrally configurable. In a production environment, part of this configuration may be read from an XML or JSON file. The user of this aspect will only need to specify which kind of strategy is required for the target method by specifying a StrategyKind, a new enum we will define. Then the target method will obtain the resilience pipeline from our IResiliencePipelineFactory, which will map the StrategyKind to a Polly ResiliencePipeline or ResiliencePipeline<T> object. The ResiliencePipelineFactory implementation is supplied by the dependency injection framework. Its implementation would typically differ in a testing or production environment.

StrategyKind

This enum represents the kinds of strategies that are available to business code. You can extend it at will.

1using Metalama.Framework.Aspects;
2
3[RunTimeOrCompileTime]
4public enum StrategyKind
5{
6    Retry
7}

We can imagine having several aspect custom attributes for each StrategyKind. You can add parameters to strategies, as long as these parameters can be implemented as properties of a custom attribute.

IResiliencePipelineFactory

This interface is responsible for returning an instance of the ResiliencePipeline or ResiliencePipeline<T> class that corresponds to the given StrategyKind.

1using Polly;
2
3public interface IResiliencePipelineFactory : IDisposable
4{
5    ResiliencePipeline GetPipeline( StrategyKind strategyKind );
6
7    ResiliencePipeline<T> GetPipeline<T>( StrategyKind strategyKind );
8}

ResiliencePipelineFactory

Here is a minimalistic implementation of the IResiliencePipelineFactory class. You can make it as complex as necessary, but this goes beyond the scope of this example. This class must be added to the dependency collection.

1using Polly;
2using Polly.Registry;
3using Polly.Retry;
4
5internal class ResiliencePipelineFactory : IResiliencePipelineFactory
6{
7    private readonly ResiliencePipelineRegistry<StrategyKind> _registry = new();
8
9    public ResiliencePipeline GetPipeline( StrategyKind strategyKind )
10        => this._registry.GetOrAddPipeline(
11            strategyKind,
12            ( builder, context ) =>
13            {
14                switch ( context.PipelineKey )
15                {
16                    case StrategyKind.Retry:
17                        builder.AddRetry(
18                            new RetryStrategyOptions
19                            {
20                                ShouldHandle = new PredicateBuilder().Handle<Exception>(),
21                                Delay = TimeSpan.FromSeconds( 1 ),
22                                BackoffType = DelayBackoffType.Exponential,
23                                MaxRetryAttempts = 10
24                            } );
25
26                        break;
27
28                    default:
29                        throw new ArgumentOutOfRangeException( nameof(strategyKind) );
30                }
31            } );
32
33    public ResiliencePipeline<T> GetPipeline<T>( StrategyKind strategyKind )
34        => this._registry.GetOrAddPipeline<T>(
35            strategyKind,
36            ( builder, context ) =>
37            {
38                switch ( context.PipelineKey )
39                {
40                    case StrategyKind.Retry:
41                        builder.AddRetry(
42                            new RetryStrategyOptions<T>
43                            {
44                                ShouldHandle = new PredicateBuilder<T>().Handle<Exception>(),
45                                Delay = TimeSpan.FromSeconds( 1 ),
46                                BackoffType = DelayBackoffType.Exponential,
47                                MaxRetryAttempts = 10
48                            } );
49
50                        break;
51
52                    default:
53                        throw new ArgumentOutOfRangeException( nameof(strategyKind) );
54                }
55            } );
56
57    public void Dispose()
58    {
59        this._registry.Dispose();
60    }
61}

Business code

Let's compare the source business code and the transformed business code.

Source Code





1internal class RemoteCalculator
2{
3    private static int _attempts;
4
5    [Retry]
6    public int Add( int a, int b )
7    {




8        // Let's pretend this method executes remotely
9        // and can fail for network reasons.
10
11        Thread.Sleep( 10 );
12
13        _attempts++;
14        Console.WriteLine( $"Attempt #{_attempts}." );
15
16        if ( _attempts <= 3 )
17        {
18            throw new InvalidOperationException();
19        }
20
21        Console.WriteLine( $"Succeeded." );
22
23        return a + b;
24    }






25




26    [Retry]
27    public async Task<int> AddAsync( int a, int b )
28    {







29        // Let's pretend this method executes remotely
30        // and can fail for network reasons.
31
32        await Task.Delay( 10 );
33
34        _attempts++;
35        Console.WriteLine( $"Attempt #{_attempts}." );
36
37        if ( _attempts <= 3 )
38        {
39            throw new InvalidOperationException();
40        }
41
42        Console.WriteLine( $"Succeeded." );
43
44        return a + b;







45    }
46}
Transformed Code
1using System;
2using System.Threading;
3using System.Threading.Tasks;
4using Microsoft.Extensions.Logging;
5
6internal class RemoteCalculator
7{
8    private static int _attempts;
9
10    [Retry]
11    public int Add( int a, int b )
12    {
13object? ExecuteCore()
14        {
15            try
16            {
17                // Let's pretend this method executes remotely
18                // and can fail for network reasons.
19
20                Thread.Sleep( 10 );
21
22        _attempts++;
23        Console.WriteLine( $"Attempt #{_attempts}." );
24
25        if ( _attempts <= 3 )
26        {
27            throw new InvalidOperationException();
28        }
29
30        Console.WriteLine( $"Succeeded." );
31
32        return a + b;
33}
34            catch (Exception e)
35            {
36                _logger.LogWarning($"RemoteCalculator.Add(a = {{{a}}}, b = {{{b}}}) has failed: {e.Message}");
37                throw;
38            }
39        }
40
41        var pipeline = _resiliencePipelineFactory.GetPipeline(StrategyKind.Retry);
42        return (int)pipeline.Execute(ExecuteCore);
43    }
44
45    [Retry]
46    public async Task<int> AddAsync( int a, int b )
47    {
48async Task<object?> ExecuteCoreAsync(CancellationToken cancellationToken) { try { return await this.AddAsync_Source(a, b); } catch (Exception e) { _logger.LogWarning($"RemoteCalculator.AddAsync(a = {{{a}}}, b = {{{b}}}) has failed: {e.Message}"); throw; } }
49
50        var pipeline = _resiliencePipelineFactory.GetPipeline(StrategyKind.Retry);
51        return (int)await pipeline.ExecuteAsync(async token => await ExecuteCoreAsync(token), CancellationToken.None);
52    }
53private async Task<int> AddAsync_Source(int a, int b)
54    {
55        // Let's pretend this method executes remotely
56        // and can fail for network reasons.
57
58        await Task.Delay( 10 );
59
60        _attempts++;
61        Console.WriteLine( $"Attempt #{_attempts}." );
62
63        if ( _attempts <= 3 )
64        {
65            throw new InvalidOperationException();
66        }
67
68        Console.WriteLine( $"Succeeded." );
69
70        return a + b;
71    }
72    private ILogger _logger;
73    private IResiliencePipelineFactory _resiliencePipelineFactory;
74
75    public RemoteCalculator(ILogger<RemoteCalculator> logger = null, IResiliencePipelineFactory? resiliencePipelineFactory = null)
76    {
77        this._logger = logger ?? throw new System.ArgumentNullException(nameof(logger)); this._resiliencePipelineFactory = resiliencePipelineFactory ?? throw new System.ArgumentNullException(nameof(resiliencePipelineFactory));
78    }
79}

Aspect implementation

Here is the source code of the Retry aspect:

1using Metalama.Extensions.DependencyInjection;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Code.SyntaxBuilders;
5using Microsoft.Extensions.Logging;
6
7#pragma warning disable CS8618, CS0649
8
9public class RetryAttribute : OverrideMethodAspect
10{
11    [IntroduceDependency]
12    private readonly ILogger _logger;
13
14    [IntroduceDependency]
15    private readonly IResiliencePipelineFactory _resiliencePipelineFactory;
16
17    public StrategyKind Kind { get; }
18
19    public RetryAttribute( StrategyKind kind = StrategyKind.Retry )
20    {
21        this.Kind = kind;
22    }
23
24    // Template for non-async methods.
25    public override dynamic? OverrideMethod()
26    {
27        if ( meta.Target.Method.ReturnType.SpecialType == SpecialType.Void )
28        {
29            void ExecuteVoid()
30            {
31                try
32                {
33                    meta.Proceed();
34                }
35                catch ( Exception e )
36                {
37                    var messageBuilder = LoggingHelper.BuildInterpolatedString( false );
38                    messageBuilder.AddText( " has failed: " );
39                    messageBuilder.AddExpression( e.Message );
40                    this._logger.LogWarning( (string) messageBuilder.ToValue() );
41
42                    throw;
43                }
44            }
45
46            var pipeline = this._resiliencePipelineFactory.GetPipeline( this.Kind );
47            pipeline.Execute( ExecuteVoid );
48
49            return null; // Dummy
50        }
51        else
52        {
53            object? ExecuteCore()
54            {
55                try
56                {
57                    return meta.Proceed();
58                }
59                catch ( Exception e )
60                {
61                    var messageBuilder = LoggingHelper.BuildInterpolatedString( false );
62                    messageBuilder.AddText( " has failed: " );
63                    messageBuilder.AddExpression( e.Message );
64                    this._logger.LogWarning( (string) messageBuilder.ToValue() );
65
66                    throw;
67                }
68            }
69
70            var pipeline = this._resiliencePipelineFactory.GetPipeline( this.Kind );
71
72            return pipeline.Execute( ExecuteCore );
73        }
74    }
75
76    // Template for async methods.
77    public override async Task<dynamic?> OverrideAsyncMethod()
78    {
79        async Task<object?> ExecuteCoreAsync( CancellationToken cancellationToken )
80        {
81            try
82            {
83                return await meta.ProceedAsync();
84            }
85            catch ( Exception e )
86            {
87                var messageBuilder = LoggingHelper.BuildInterpolatedString( false );
88                messageBuilder.AddText( " has failed: " );
89                messageBuilder.AddExpression( e.Message );
90                this._logger.LogWarning( (string) messageBuilder.ToValue() );
91
92                throw;
93            }
94        }
95
96        var cancellationTokenParameter
97            = meta.Target.Parameters.LastOrDefault( p => p.Type.Equals( typeof(CancellationToken) ) );
98
99        var pipeline = this._resiliencePipelineFactory.GetPipeline( this.Kind );
100
101        return await pipeline.ExecuteAsync(
102            async token => await ExecuteCoreAsync( token ),
103            cancellationTokenParameter != null
104                ? (CancellationToken) cancellationTokenParameter.Value!
105                : CancellationToken.None );
106    }
107}

This aspect pulls in two dependencies, ILogger and IResiliencePipelineFactory. The [IntroduceDependency] custom attribute on top of the fields introduces the fields and pulls them from the constructor.

Polly, by design, requires the logic to be wrapped in a delegate. In a Metalama template, we achieve this only with a local function because anonymous methods or lambdas can't contain calls to meta.Proceed. These local functions are named ExecuteVoid, ExecuteCore or ExecuteCoreAsync. They have an exception handler that prints a warning with the method name and all method arguments when the method fails.

We now have the best of both worlds: a fully-featured resilience framework with Polly, and boilerplate reduction with Metalama.