Open sandboxFocusImprove this doc

Retry example, step 6: Retrying calls to code you don't own

In steps 1 to 5, the [Retry] aspect overrides the method to which it is applied. This requires the source code of the method. However, the calls that fail for transient reasons are often calls to a library that you do not own, for instance the client of a remote service. You cannot add an aspect to the methods of this library.

In this example, the application calls ExchangeRateClient, a class of a separate class library. The library does not use Metalama. To simulate transient failures, each method of ExchangeRateClient fails the first two times it is called.

Instead of overriding the methods of ExchangeRateClient, Metalama rewrites the calls to these methods in the source code of the application. Each call calls a generated interceptor method, which retries the call. The calling code itself does not change in the source code:

Source Code
1using Metalama.Samples.Retry.ExchangeRates;
2
3namespace Metalama.Samples.Retry6;
4
5public class PriceService
6{
7    private readonly ExchangeRateClient _client = new();
8
9    public decimal ConvertPrice( decimal price, string currency ) => price * this._client.GetRate( currency );
10
11    public async Task<decimal> ConvertPriceAsync( decimal price, string currency, CancellationToken cancellationToken )
12        => price * await this._client.GetRateAsync( currency, cancellationToken );
13
14    public Task<string> BuyCurrencyAsync( string currency, decimal amount, CancellationToken cancellationToken )
15        => this._client.BuyCurrencyAsync( currency, amount, cancellationToken );
16}
17
Transformed Code
1using Metalama.Samples.Retry.ExchangeRates;
2
3namespace Metalama.Samples.Retry6;
4
5public class PriceService
6{
7    private readonly ExchangeRateClient _client = new();
8
9    public decimal ConvertPrice( decimal price, string currency ) => price * MetalamaInterceptors.ExchangeRateClient_GetRate_Interceptor( this._client,currency:currency );
10
11    public async Task<decimal> ConvertPriceAsync( decimal price, string currency, CancellationToken cancellationToken )
12        => price * await MetalamaInterceptors.ExchangeRateClient_GetRateAsync_Interceptor( this._client,currency:currency,cancellationToken:cancellationToken );
13
14    public Task<string> BuyCurrencyAsync( string currency, decimal amount, CancellationToken cancellationToken )
15        => this._client.BuyCurrencyAsync( currency, amount, cancellationToken );
16}
17

When the program runs, it produces the following output:

The exchange rate service did not respond in time. Waiting 200 ms.
The exchange rate service did not respond in time. Waiting 400 ms.
100 USD = 92.00 EUR
The exchange rate service did not respond in time. Waiting 200 ms.
The exchange rate service did not respond in time. Waiting 400 ms.
100 USD = 79.00 GBP
Order 1: 50 EUR

Implementation

The project references the Metalama.Extensions.Interceptors package. The retry logic is implemented by the following project fabric:

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Advising;
3using Metalama.Framework.Aspects;
4using Metalama.Framework.Fabrics;
5using Metalama.Samples.Retry.ExchangeRates;
6
7namespace Metalama.Samples.Retry6;
8
9// Retries the calls to the idempotent methods of ExchangeRateClient, a class of a library that you do not own.
10internal class RetryFabric : ProjectFabric
11{
12    /// <summary>
13    /// Gets the maximum number of times that the call should be retried.
14    /// </summary>
15    private int Attempts => 3;
16
17    /// <summary>
18    /// Gets the delay, in ms, to wait before the first retry.
19    /// The delay is doubled at every further retry.
20    /// </summary>
21    private double Delay => 100;
22
23    public override void AmendProject( IProjectAmender amender )
24        => amender.InterceptMethods(
25            calls => calls
26                .Type( typeof(ExchangeRateClient) )
27                .AllMethods()
28
29                // Retry only the methods that read data. Retrying BuyCurrencyAsync could place the order twice.
30                .Where( m => m.Name.StartsWith( "Get", StringComparison.Ordinal ) )
31                .RedirectToSynthesizedMethod(
32                    new MethodTemplateSelector( nameof(this.Retry), nameof(this.RetryAsync) ) ) );
33
34    // Template for non-async methods.
35    [Template]
36    public dynamic? Retry()
37    {
38        for ( var i = 0;; i++ )
39        {
40            try
41            {
42                return meta.Proceed();
43            }
44            catch ( Exception e ) when ( i < this.Attempts )
45            {
46                var delay = this.Delay * Math.Pow( 2, i + 1 );
47                Console.WriteLine( e.Message + $" Waiting {delay} ms." );
48                Thread.Sleep( (int) delay );
49            }
50        }
51    }
52
53    // Template for async methods. The cancellationToken parameter binds by name to the parameter of the
54    // intercepted method.
55    [Template]
56    public async Task<dynamic?> RetryAsync( CancellationToken cancellationToken = default )
57    {
58        for ( var i = 0;; i++ )
59        {
60            try
61            {
62                return await meta.ProceedAsync();
63            }
64            catch ( Exception e ) when ( i < this.Attempts )
65            {
66                var delay = this.Delay * Math.Pow( 2, i + 1 );
67                Console.WriteLine( e.Message + $" Waiting {delay} ms." );
68                await Task.Delay( (int) delay, cancellationToken );
69            }
70        }
71    }
72}
73

The AmendProject method registers the interceptors with the InterceptMethods method:

  • Type( typeof(ExchangeRateClient) ).AllMethods() selects all the methods that ExchangeRateClient declares. Metalama intercepts the calls to these methods in the source code of the current project.
  • Where keeps only the methods whose name starts with Get. These methods read data, so calling them twice has the same effect as calling them once. In other words, they are idempotent. BuyCurrencyAsync is not idempotent: if the first call places the order but fails before it returns, a retry places a second order. Therefore, the calls to BuyCurrencyAsync are not intercepted.
  • RedirectToSynthesizedMethod generates the interceptor methods from the templates of the fabric. The MethodTemplateSelector gives two templates: Retry for the methods that are not async, and RetryAsync for the async methods.

The Retry and RetryAsync templates have the same code as the templates of the [Retry] aspect of step 3, with the following differences:

  • meta.Proceed() and meta.ProceedAsync() call the intercepted method of ExchangeRateClient, with the arguments of the call site.
  • RetryAsync declares a run-time parameter cancellationToken, and passes it to Task.Delay. Metalama binds the run-time parameters of the template to the parameters of the interceptor by name, so cancellationToken receives the CancellationToken argument of the call. For details, see Interceptor binding rules.
  • The maximum number of attempts and the delay are properties of the fabric, instead of properties of the aspect.

Metalama generates the interceptor methods in an internal static class named MetalamaInterceptors, in the global namespace. The interceptor of GetRateAsync is an async method, and it awaits the call to GetRateAsync inside the retry loop.

Comparison with steps 1 to 5

Steps 1 to 5: aspect Step 6: interceptors
What changes The implementation of the method. The calls to the method, in the current project.
Source code of the method Required. Not required. The method can be in any assembly.
Selection of the methods You add the [Retry] attribute to each method. A fabric selects the methods with a query, for instance by name.
Callers that retry All the callers of the method. Only the callers in the projects that register the interceptors.

Use the aspect of steps 1 to 5 when you own the method and all its callers must retry it. Use interceptors when you do not own the method, or when only some callers must retry it.