Hi Everyone!

This is continuation of the series of posts on Polly v8 and .NET 8.

In this series of posts, I will try to cover some of the new features of Polly v8 and .NET 8. Below are the topics I am planning to cover in this series :

Implementing Retry Strategy for HttpClientFactory using Polly(v8) and .NET 8

This Post - Re-Authorize Efficiently Using Polly And .NET HttpClientFactory in .NET 8

Implementing Timeout Strategy for HttpClientFactory using Polly(v8) and .NET 8

Implementing CircuitBreaker Strategy for HttpClientFactory using Polly(v8) and .NET 8

Implementing RateLimiter Strategy for HttpClientFactory using Polly(v8) and .NET 8

Implementing Multiple Strategy for HttpClientFactory using Polly(v8) and .NET 8

Implementing Telemetry for HttpClientFactory using Polly(v8) and .NET 8

In the last post, we have seen how to implement Retry Strategy for HttpClientFactory using Polly(v8) and .NET 8. In this post, we will see how to re-authorize efficiently using Polly and .NET HttpClientFactory in .NET 8.

Please note, This post is only for .NET 8. For Polly v8 along with .NET 6 and .NET 7 probably this post will help you however I have not tested it yet.

Also note, You can refer this post if you are not using Polly v8.

Setup

For demonstration purpose, I have created a .NET 8 Web API project so that we can inject fault in our API randomly.

Our API is very simple and it has only one endpoint to get the weather forecast. Below is the implementation of the same -

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();


var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// Weather API
var summaries = new[]
{
    "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast",  ([FromHeader(Name = "Authorization")] string customHeader) =>
{
    var rnd = Random.Shared.Next(0, 100);

    Console.WriteLine(customHeader);

    switch (rnd)
    {
        case > 60:
            return Results.Problem("Something went wrong");
        case > 30:
            return Results.Unauthorized();
        default:
        {
            var forecast = Enumerable.Range(1, 5).Select(index =>
                    new WeatherForecast
                    (
                        DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                        Random.Shared.Next(-20, 55),
                        summaries[Random.Shared.Next(summaries.Length)]
                    ))
                .ToArray();
            return Results.Ok(forecast);
        }
    }
})
.WithName("GetWeatherForecast")
.WithOpenApi();

app.Run();

record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

We have another console application which will call this API. Below is the implementation of the same -

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using Sundry.HttpClientDemisfied.Console;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ICachedTokenService, CachedTokenService>();
builder.Services.AddSingleton<IExternalTokenService, ExternalTokenService>();

builder.Services.AddTransient<TokenRetrievalHandler>();

var httpClientBuilder = builder.Services.AddHttpClient<IWeatherForecast, WeatherForecast>(client =>
{
    client.BaseAddress = new Uri("http://localhost:5298/");
});
    httpClientBuilder.AddResilienceHandler("Retry", (resiliencePipelineBuilder, context) =>
    {
        resiliencePipelineBuilder
            .AddRetry(new HttpRetryStrategyOptions
            {
                ShouldHandle = args => args.Outcome switch
                {
                    { Exception: HttpRequestException } => PredicateResult.True(),
                    { Result.StatusCode: HttpStatusCode.Unauthorized } => PredicateResult.False(),
                    { Result.IsSuccessStatusCode: false } => PredicateResult.True(),
                    _ => PredicateResult.False()
                },
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromSeconds(10),
                UseJitter = true
            })
            .AddRetry(new HttpRetryStrategyOptions
            {
                ShouldHandle = args => args.Outcome switch
                {
                    { Result.StatusCode: HttpStatusCode.Unauthorized } => PredicateResult.True(),
                    _ => PredicateResult.False()
                },
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromSeconds(10),
                UseJitter = true,
                OnRetry = async (outcome) =>
                {
                    await context.ServiceProvider.GetRequiredService<ICachedTokenService>().RefreshTokenAsync(outcome.Context);
                }
            });
    });

    httpClientBuilder.AddHttpMessageHandler<TokenRetrievalHandler>();

using var host = builder.Build();
await ExemplifyServiceLifetime(host.Services);

await host.RunAsync();
return;

async Task ExemplifyServiceLifetime(IServiceProvider hostProvider)
{
    using var scope = hostProvider.CreateScope();
    var provider = scope.ServiceProvider;
    var weatherForecast = provider.GetRequiredService<IWeatherForecast>();
    var forecasts = await weatherForecast.GetWeatherForecastAsync();
    if (forecasts is not null)
    {
        foreach (var forecast in forecasts)
        {
            Console.WriteLine(forecast);
        }
    }
}

Couple of things to note here -

  1. We are using Microsoft.Extensions.Http.Resilience package to implement the retry strategy, which is a wrapper around Polly v8 and provides a way to implement Polly strategies for HttpClientFactory in .NET 8.

  2. We are using AddResilienceHandler to add the retry strategies. We are using two different retry strategy here. First one is to retry 3 times with a delay of 10 seconds in case of any transient error. Second one is to retry 3 times with a delay of 10 seconds in case of Unauthorized error. Also, we are refreshing the Token in case of Unauthorized error.

  3. We are using AddHttpMessageHandler to add the TokenRetrievalHandler to intercept the call and add the Token in the header while retrying.

  4. We are using ICachedTokenService to get the Token from the memory cache and refresh it from IExternalTokenService when it is expired.

  5. We are using IExternalTokenService to get the actual Token from external service like Auth0 and refresh it when it is expired.

  6. We are using AddHttpClient to add the IWeatherForecast as HttpClient.

Explanation

Let’s start with the AccessToken record which will be used to store the Token -

AccessToken.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
public record Token
{
    public static Token Empty => new();

    [JsonPropertyName("token_type")]
    public string Scheme { get; set; } = default!;

    [JsonPropertyName("access_token")]
    public string AccessToken { get; set; } = default!;

    [JsonPropertyName("expires_in")]
    public double ExpiresIn { get; set; } = default!;
}

Next, To simulate the scenario, we are generating a random Guid as Token from IExternalTokenService. Abstartion for the same is as below -

IExternalTokenService.cs
1
2
3
4
public interface IExternalTokenService
{
    Task<Token> GetTokenAsync();
}

Below is the implementation of the same -

ExternalTokenService.cs
1
2
3
4
5
6
public class ExternalTokenService : IExternalTokenService
{
    public Task<Token> GetTokenAsync()=>  Task.FromResult(new Token()
            { AccessToken = Guid.NewGuid().ToString("N"), ExpiresIn = 3900, Scheme = "Bearer" });
    
}

In real world, we will get the Token from external service like Auth0 / Azure Entra etc.

Next, we will create the abstraction for ICachedTokenService as below -

ICachedTokenService.cs
1
2
3
4
5
public interface ICachedTokenService
{
    ValueTask<Token> GetTokenAsync(ResilienceContext context);
    Task<Token> RefreshTokenAsync(ResilienceContext context);
}

Below is the implementation of the same -

CachedTokenService.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class CachedTokenService : ICachedTokenService
{   
    private const string CacheKey = nameof(CachedTokenService);
    private readonly IMemoryCache _memoryCache;
    private readonly IExternalTokenService _externalTokenService;
    public CachedTokenService(IMemoryCache memoryCache, IExternalTokenService externalTokenService)
    {
        _memoryCache = memoryCache;
        _externalTokenService = externalTokenService;
    }
    public async ValueTask<Token> GetTokenAsync(ResilienceContext context)
    {
        if (!_memoryCache.TryGetValue(CacheKey, out Token? cacheValue))
        {
            cacheValue = await RefreshTokenAsync(context);
        }
        return cacheValue!;
    }

    public async Task<Token> RefreshTokenAsync(ResilienceContext context)
    {
        var token = await _externalTokenService.GetTokenAsync();
        
        if (token != Token.Empty)
        {
           var expiresIn = token.ExpiresIn>0?token.ExpiresIn-10:token.ExpiresIn;
           
           _memoryCache.Set(CacheKey, token, new MemoryCacheEntryOptions()
                .SetSlidingExpiration(TimeSpan.FromMinutes(5))
                .SetAbsoluteExpiration(TimeSpan.FromSeconds(expiresIn)));        
        }

        context.Properties.Set(new ResiliencePropertyKey<Token>("AccessKey"), token);
        
        return token;
    }
}

Here, couple of things to note -

  1. We are using ValueTask for GetTokenAsync. This is because we know that the Hot path will be to get the Token from the memory cache and very few scenraio(e.g very first time when Downstream is getting hit) it will actualy call the External Auth Server. So, we are using ValueTask to avoid the overhead of allocating a new Task in the heap.

  2. We are using SetAbsoluteExpiration and SetSlidingExpiration both to make sure we are not overwhelming by storing the unused Token in the memory cache. Also to avoid edge case when the Token is expired but not yet refreshed we are making sure it removed from the cache before it’s actual expiry time.

  3. We are using ResilienceContext from Polly to store the Token in the context so that we can use it in TokenRetrievalHandler.

Next, we will write custom DelegationHandler to intercept the call and add the Token in the header while retrying. Below is the implementation of the same -

TokenRetrievalHandler.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TokenRetrievalHandler : DelegatingHandler
{   
    private readonly ICachedTokenService _cachedTokenService;

    public TokenRetrievalHandler(ICachedTokenService cachedTokenService)
    {
        _cachedTokenService = cachedTokenService;
    }
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var context =  ResilienceContextPool.Shared.Get(cancellationToken);
        context.Properties.TryGetValue(new ResiliencePropertyKey<Token>("AccessToken"), out var token);
       
        token ??= await _cachedTokenService.GetTokenAsync(context);

        request.Headers.Authorization = new AuthenticationHeaderValue(token.Scheme, token.AccessToken);
        return await base.SendAsync(request, cancellationToken);
    }
}

Things to note here -

  1. We are using ResilienceContextPool from Polly to get the context and then get the Token from the context. If the Token is not available in the context then we are getting it from ICachedTokenService.

  2. We are adding the Token in the AuthenticationHeader.

At last, we will create the abstraction for IWeatherForecast as below -

IWeatherForecast.cs
1
2
3
4
public interface IWeatherForecast
{ 
    Task<IEnumerable<Weather>?> GetWeatherForecastAsync();
}

Below is the implementation of the same -

WeatherForecast.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class  WeatherForecast : IWeatherForecast{
    private readonly HttpClient _httpClient;

    public WeatherForecast(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<IEnumerable<Weather>?> GetWeatherForecastAsync()
    {
        var response = await _httpClient.GetAsync("/weatherforecast");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<IEnumerable<Weather>>();
    }   
}


public record Weather(DateOnly Date, int TemperatureC, string? Summary);

All the pieces are in place. Now, we just need to orchestrate the flow. Let’s revisit the Program.cs file where we are configuring the HttpClientFactory -

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var httpClientBuilder = builder.Services.AddHttpClient<IWeatherForecast, WeatherForecast>(client =>
{
    client.BaseAddress = new Uri("http://localhost:5298/");
});
    httpClientBuilder.AddResilienceHandler("Retry", (resiliencePipelineBuilder, context) =>
    {
        resiliencePipelineBuilder
            .AddRetry(new HttpRetryStrategyOptions
            {
                ShouldHandle = args => args.Outcome switch
                {
                    { Exception: HttpRequestException } => PredicateResult.True(),
                    { Result.StatusCode: HttpStatusCode.Unauthorized } => PredicateResult.False(),
                    { Result.IsSuccessStatusCode: false } => PredicateResult.True(),
                    _ => PredicateResult.False()
                },
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromSeconds(10),
                UseJitter = true
            })
            .AddRetry(new HttpRetryStrategyOptions
            {
                ShouldHandle = args => args.Outcome switch
                {
                    { Result.StatusCode: HttpStatusCode.Unauthorized } => PredicateResult.True(),
                    _ => PredicateResult.False()
                },
                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromSeconds(10),
                UseJitter = true,
                OnRetry = async (outcome) =>
                {
                    await context.ServiceProvider.GetRequiredService<ICachedTokenService>().RefreshTokenAsync(outcome.Context);
                }
            });
    });

    httpClientBuilder.AddHttpMessageHandler<TokenRetrievalHandler>();

AddResilienceHandler is an extension method provided by Microsoft.Extensions.Http.Resilience package. It takes two parameters, first one is the name of the pipeline and second one is the action to configure the pipeline. It does not return IHttpClientBuilder instead it returns IHttpResiliencePipelineBuilder provides a way to configure the resilience pipeline.

Hence, we are keeping the reference of IHttpClientsBuilder in httpClientBuilder and then using it to configure the pipeline and adding the TokenRetrievalHandler to intercept the call and add the Token in the header while retrying.

Sequence Diagram

Below is the sequence diagram for the same -

sequenceDiagram
title HttpClient Retry Resiliency While UnAuthorized

actor HttpCient
Note over HttpCient,Polly Delegation Handler: PostAsync/GetAsync etc.
Calls SendAsync inernally HttpCient->>+ Polly Delegation Handler: Calls SendAsync Polly Delegation Handler->>+Pipeline: Calls ExecuteOutcomeAsync Pipeline->>+Retry: Calls ExecuteCore Retry->>+Token Retrival Delegation Handler: Calls SendAsync alt Token is Available Token Retrival Delegation Handler->>+ResilienceContextPool : Calls TryGetValue ResilienceContextPool->>-Token Retrival Delegation Handler : Returns Token else Token is not Available Token Retrival Delegation Handler->>+Cached Token Service : Calls GetTokenAsync Cached Token Service->>-Token Retrival Delegation Handler : Returns Token end alt Token is Expired Cached Token Service->>+ External Token Service: Calls RefreshTokenAsync External Token Service->>-Cached Token Service: Returns Token else Token is not Expired Cached Token Service->>+ Cached Token Service: Returns Cached Token end Token Retrival Delegation Handler->>Token Retrival Delegation Handler : Sets Authentication Header Note over Retry,Downstream Service: Initial Attempt Retry->>+Downstream Service: Invoke Downstream Service->>-Retry: Unauthorized (for Demonstration Purpose Only, because it is very unlikely it will return Unauthorized in very first Invoke just after getting fresh token) Retry-->Retry:Sleeps alt When Token is Valid Note over Downstream Service,Retry: 1st Retry Attempt Retry->>+Downstream Service: Invoke Downstream Service->>-Retry: Returns Valid Response Retry->>+Pipeline: Returns Valid Response Pipeline->>+Polly Delegation Handler: Returns Valid Response Polly Delegation Handler->>+HttpCient: Returns Valid Response else When Token is not Valid Note over Downstream Service,Retry: 1st Retry Attempt Retry->>+Downstream Service: Invoke alt Token is Available Token Retrival Delegation Handler->>+ResilienceContextPool : Calls TryGetValue ResilienceContextPool->>-Token Retrival Delegation Handler : Returns Token else Token is not Available Token Retrival Delegation Handler->>+Cached Token Service : Calls GetTokenAsync Cached Token Service->>-Token Retrival Delegation Handler : Returns Token end alt Token is Expired Cached Token Service->>+ External Token Service: Calls RefreshTokenAsync External Token Service->>-Cached Token Service: Returns Token else Token is not Expired Cached Token Service->>+ Cached Token Service: Returns Cached Token end Downstream Service->>-Retry: Returns Valid Response Retry->>+Pipeline: Returns Valid Response Pipeline->>+Polly Delegation Handler: Returns Valid Response Polly Delegation Handler->>+HttpCient: Returns Valid Response end

Result

Finally, we are ready to run the application. Let’s run the Web API first and then Console Application.

In my case, below is the output of the Console -

Console Output Console Output

and below is the output of the Web API -

Web API Output Web API Output

Let’s try to understand the output -

  1. Very first attempt is unsuccessful and it is returning 500 which can be seen from the Console output. Since we are capturing the Authorization header in the Web API we can see that the Token is also getting logged in the Web API output. In our case, it is Bearer 201bf10e73164ea288ddc21d58fa0964. This is will be handled by the first retry strategy which is to retry 3 times with a delay of 10 seconds in case of any transient error.

  2. Second attempt is also unsuccessful and it is returning 401. In this case we could see the same Token is getting logged in the Web API output as expected.

  3. Since in the second attempt we got 401 which is handled by the second retry strategy which is to retry 3 times with a delay of 10 seconds in case of Unauthorized error. This will refresh the Token and then retry the call. In our case, we could see that the Token is getting refreshed and the different Token is getting logged in the Web API output.In our case, it is Bearer a4b1af59a33e46db8251ff7bb5d4a5c4.

  4. To keep it short, I will not go through the third attempt. But I hope you got the idea that in case of Unauthorized error, it will refresh the Token and then retry the call and in case of any other error it will retry the call without refreshing the Token.

  5. Let’s jump to the last attempt which is successful and it is returning 200. Before retrying the call, it will refresh the Token as it got 401 in the previous attempt. In this case we could see that the different Token is getting logged in the Web API output as expected, which is Bearer 503b1c11dc66449cabf20fa04198c816.

Conclusion

IMO, it is now more easier to implement than the pervious version of Polly. But I did had a hard time to figure out how to add DelegationHandler while retrying, because the documentation is not very clear on that. I hope this post will help you to implement the same.

You can find the complete source code here