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 policies, 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 policy 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 policy is required for
the target method by specifying a PolicyKind
, a new enum
we will define. Then the target method will obtain the
policy from our a IPolicyFactory
, which will map the PolicyKind
to a Polly Policy
object. The PolicyFactory
implementation is supplied by the dependency injection framework. Its implementation would typically differ in a testing
or production environment.
PolicyKind
This enum
represents the kinds of policies that are available to business code. You can extend it at will.
1using Metalama.Framework.Aspects;
2
3[RunTimeOrCompileTime]
4public enum PolicyKind
5{
6 Retry
7}
We can imagine having several aspect custom attributes for each PolicyKind
. You can add parameters to policies, as
long as these parameters can be implemented as properties of a custom attribute.
IPolicyFactory
This interface is responsible for returning an instance of the Policy
class that corresponds to the
given PolicyKind
.
1using Polly;
2
3public interface IPolicyFactory
4{
5 Policy GetPolicy(PolicyKind policyKind);
6 AsyncPolicy GetAsyncPolicy(PolicyKind policyKind);
7}
PolicyFactory
Here is a minimalistic implementation of the IPolicyFactory
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.Retry;
3
4internal class PolicyFactory : IPolicyFactory
5{
6 private static readonly RetryPolicy _retry = Policy.Handle<Exception>().WaitAndRetry(
7 new[]
8 {
9 TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4),
10 TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30)
11 });
12
13 private static readonly AsyncRetryPolicy _asyncRetry = Policy.Handle<Exception>()
14 .WaitAndRetryAsync(
15 new[]
16 {
17 TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4),
18 TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(15),
19 TimeSpan.FromSeconds(30)
20 });
21
22
23 public Policy GetPolicy(PolicyKind policyKind)
24 => policyKind switch
25 {
26 PolicyKind.Retry => _retry,
27 _ => throw new ArgumentOutOfRangeException(nameof(policyKind))
28 };
29
30 public AsyncPolicy GetAsyncPolicy(PolicyKind policyKind)
31 => policyKind switch
32 {
33 PolicyKind.Retry => _asyncRetry,
34 _ => throw new ArgumentOutOfRangeException(nameof(policyKind))
35 };
36}
Business code
Let's compare the source business code and the transformed business 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($"Trying for the {_attempts}-th time.");
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($"Trying for the {_attempts}-th time.");
36
37 if (_attempts <= 3)
38 {
39 throw new InvalidOperationException();
40 }
41
42 Console.WriteLine($"Succeeded.");
43
44 return a + b;
45 }
46}
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($"Trying for the {_attempts}-th time.");
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 policy = _policyFactory.GetPolicy(PolicyKind.Retry);
42 return (int)policy.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 policy = _policyFactory.GetAsyncPolicy(PolicyKind.Retry);
51 return (int)await policy.ExecuteAsync(ExecuteCoreAsync, 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($"Trying for the {_attempts}-th time.");
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 IPolicyFactory _policyFactory;
74
75 public RemoteCalculator(ILogger<RemoteCalculator> logger = default(global::Microsoft.Extensions.Logging.ILogger<global::RemoteCalculator>), IPolicyFactory? policyFactory = default(global::IPolicyFactory?))
76 {
77 this._logger = logger ?? throw new System.ArgumentNullException(nameof(logger)); this._policyFactory = policyFactory ?? throw new System.ArgumentNullException(nameof(policyFactory));
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] private readonly ILogger _logger;
12
13 [IntroduceDependency] private readonly IPolicyFactory _policyFactory;
14
15 public PolicyKind Kind { get; }
16
17 public RetryAttribute(PolicyKind kind = PolicyKind.Retry)
18 {
19 this.Kind = kind;
20 }
21
22 // Template for non-async methods.
23 public override dynamic? OverrideMethod()
24 {
25 object? ExecuteCore()
26 {
27 try
28 {
29 return meta.Proceed();
30 }
31 catch (Exception e)
32 {
33 var messageBuilder = LoggingHelper.BuildInterpolatedString(false);
34 messageBuilder.AddText(" has failed: ");
35 messageBuilder.AddExpression(e.Message);
36 this._logger.LogWarning((string)messageBuilder.ToValue());
37
38 throw;
39 }
40 }
41
42 var policy = this._policyFactory.GetPolicy(this.Kind);
43 return policy.Execute(ExecuteCore);
44 }
45
46 // Template for async methods.
47 public override async Task<dynamic?> OverrideAsyncMethod()
48 {
49 async Task<object?> ExecuteCoreAsync(CancellationToken cancellationToken)
50 {
51 try
52 {
53 return await meta.ProceedAsync();
54 }
55 catch (Exception e)
56 {
57 var messageBuilder = LoggingHelper.BuildInterpolatedString(false);
58 messageBuilder.AddText(" has failed: ");
59 messageBuilder.AddExpression(e.Message);
60 this._logger.LogWarning((string)messageBuilder.ToValue());
61
62 throw;
63 }
64 }
65
66 var cancellationTokenParameter
67 = meta.Target.Parameters.LastOrDefault(p => p.Type.Is(typeof(CancellationToken)));
68
69 var policy = this._policyFactory.GetAsyncPolicy(this.Kind);
70 return await policy.ExecuteAsync(ExecuteCoreAsync,
71 cancellationTokenParameter != null
72 ? (CancellationToken)cancellationTokenParameter.Value!
73 : CancellationToken.None);
74 }
75}
This aspect pulls in two dependencies, ILogger
and IPolicyFactory
.
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 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.