1

I found this answer to be woefully incomplete:

Refresh Token using Polly with Typed Client

Same question as in this post, except the accepted answer seems critically flawed. Every time you make a request, you're going to get a 401 error, then you obtain an access token, attach it to the request and try again. It works, but you take an error on every single message.

The only solution I see is to set the default authentication header, but to do that, you need the HttpClient. So the original answer would need to do something like this:

services.AddHttpClient<TypedClient>()
    .AddPolicyHandler((provider, request) =>
    {
        return Policy.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.Unauthorized)
            .RetryAsync(1, (response, retryCount, context) =>
            {
                var httpClient = provider.GetRequiredService<HttpClient>();
                var authService = provider.GetRequiredService<AuthService>();
                httpClient.DefaultRequestHeaders.Authorization = authService.GetAccessToken());
            });
        });
    });

So the rub is, how do you get the current HttpClient in order to set the default access token so you don't call this handler every time you make a request? We only want to invoke this handler when the token has expired.

Peter Csala
  • 17,736
  • 16
  • 35
  • 75
Quark Soup
  • 4,272
  • 3
  • 42
  • 74
  • Could you share your `Startup.cs` file for us, and you can hide the unnecessary services. So that we can reproduce the issue easily. And we also need the code about `GetAccessToken`. – Jason Pan Oct 05 '22 at 03:49
  • Does this answer your question? [Refresh Token using Polly with Named Client](https://stackoverflow.com/questions/59833373/refresh-token-using-polly-with-named-client) – Peter Csala Oct 05 '22 at 05:38
  • In the above SO topic I have provided two slightly different solutions how you can do that in a decent way with named client. Changing the code to use typed client should not be a problem I guess. – Peter Csala Oct 05 '22 at 05:40
  • @PeterCsala - Thanks for the suggestions. I understand the Named Client is a fallback. I really would like to understand how the Typed Client is supposed to work in this scenarios, though. I don't get how your solution could be adapted. – Quark Soup Oct 05 '22 at 11:26

2 Answers2

1

As I have stated in the comments I've already created two solutions:

Both of the solutions are utilizing named clients. So, here I would focus only on that part which needs to be changed to use typed client.

The good news is that you need to change only a tiny part of the solutions.

Rewriting the custom exception based solution

Only the following code needs to be changed from this:

services.AddHttpClient("TestClient")
        .AddPolicyHandler((provider, _) => GetTokenRefresher(provider))
        .AddHttpMessageHandler<TokenFreshnessHandler>();

to this:

services.AddHttpClient<ITestClient, TestClient>
        .AddPolicyHandler((provider, _) => GetTokenRefresher(provider))
        .AddHttpMessageHandler<TokenFreshnessHandler>();

The ITestClient and TestClient entities are independent from the rest of the solution.

Rewriting the context based solution

Only the following code needs to be changed

services.AddHttpClient("TestClient")
    .AddPolicyHandler((sp, request) => GetTokenRefresher(sp, request))
    .AddHttpMessageHandler<TokenRetrievalHandler>()

to this:

services.AddHttpClient<ITestClient, TestClient>
    .AddPolicyHandler((sp, request) => GetTokenRefresher(sp, request))
    .AddHttpMessageHandler<TokenRetrievalHandler>()

So the rub is, how do you get the current HttpClient in order to set the default access token so you don't call this handler every time you make a request? We only want to invoke this handler when the token has expired.

With my proposed solution you don't need to access the HttpClient to set the default header, because the currently active access token is stored in a singleton class (TokenService). Inside the DelegatingHandler (TokenFreshnessHandler/TokenRetrievalHandler) you retrieve the latest, greatest token and set it on the HttpRequestMessage.

Peter Csala
  • 17,736
  • 16
  • 35
  • 75
  • This is better, but it's still defective. When the override of SendAsync is called, the context is empty. That means that we will ask the ITokenService for an access token every time we call an HTTP method. Getting an access token is an asynchronous operation and, while most implementations have a cache, there's still overhead. Isn't there a version of this that has an access token that is persisted with the HttpClient created from the IClientFactor? – Quark Soup Oct 05 '22 at 20:31
  • @Quarkly The TokenService's GetToken is a synchronous method which returns the cached access token. No http communication is needed for the GetToken. It could be even a property. Only the refresh token requires http communication which is called only if the token is expired, not at each request. – Peter Csala Oct 05 '22 at 21:31
  • "*Only the refresh token requires http communication which is called only if the token is expired, not at each request*" That is incorrect. It is called *every* time you invoke an HttpClient function because the 'context' doesn't persist. Put a breakpoint on it. The Context is only good for passing things during the lifetime of an HttpRequestMessage, which is once per HttpClient function call. – Quark Soup Oct 05 '22 at 23:49
  • @Quarkly Are we talking about the [same code](https://stackoverflow.com/questions/59833373/refresh-token-using-polly-with-named-client/73247376#73247376)? The `TokenRetrievalHandler` class does **not** call the `RefreshToken` async method only the `GetToken` sync method. Only the policy calls the `RefreshToken` which is triggered only if the status code is 401. The sequence diagram tells the same story. – Peter Csala Oct 06 '22 at 05:22
1

The answer by Peter Csala is a great start. However, I found that the lifetime of the 'context' was only as long as the HttpRequestMessage, which is basically once per HttpClient function invocation (Put, Post, Get, Delete, Send). That is, it doesn't persist from function call to function call, so we are constantly asking the cache for a token. In MSAL, this function isn't completely trivial and involves a context switch. we should be able to just use a stored string until we get a 401 error, then it's reasonable to ask the MSAL cache for a new token.

I believe this is a more efficient version of the TokenRetrievalService handler:

public class TokenRetrievalHandler : DelegatingHandler
{
    private readonly ITokenService tokenService;
    public TokenRetrievalHandler(ITokenService tokenService)
    {
        this.tokenService = tokenService;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.tokenService.AccessToken);
        return await base.SendAsync(request, cancellationToken);
    }
}

and this is a more efficient version of the Token Service:

public class TokenService : ITokenService
{
    public string AccessToken { get; set; }
    public async Task RefreshAccessTokenAsync()
    {
        this.AccessToken = await <GetTokenFromPromptorCache>();
    }
}

And the token refresher policy then looks much simpler:

    private static IAsyncPolicy<HttpResponseMessage> GetTokenRefresher(IServiceProvider serviceProvider, HttpRequestMessage httpRequestMessage)
    {
        return Policy<HttpResponseMessage>
            .HandleResult(response => response.StatusCode == HttpStatusCode.Unauthorized)
            .RetryAsync(async (handler, retry) =>
            {
                await serviceProvider.GetRequiredService<ITokenService>().RefreshAccessTokenAsync();
            });
    }

Again, props to Peter Csala for the bulk of the work. This is a minor tweak that accomplishes the original goal of reusing the raw version of the token without bothering (for example) the MSAL cache on every call.

Quark Soup
  • 4,272
  • 3
  • 42
  • 74
  • Changing the `GetToken` sync method to an `AccessToken` property does not change anything about the flow. Getting rid of the context means that if you have another retry policy (for example which triggers for 408 and 429) then it will access the `AccessToken` for all retry attempts as well. The `context` is used to prevent these unnecessary lookups (when the retry is triggered not because of expired access token). At least that's what I understand. – Peter Csala Oct 06 '22 at 05:32
  • @PeterCsala - Simple question: what is the lifetime of a Polly context? My debugging tells me it only lasts as long as the HttpRequestMessage. – Quark Soup Oct 06 '22 at 12:18
  • According to my understanding the lifetime of the Context is not bounded to the `HttpRequestMessage`. [For example if you access it with the `ExecuteAndCaptureAsync` method then you can access the `Context` after the policy has finished its work.](https://stackoverflow.com/a/73772053/13268855). – Peter Csala Oct 06 '22 at 13:45
  • Also if you look at the source code of [`PolicyHttpMessageHandler`](https://github.com/dotnet/aspnetcore/blob/0ee742c53f2669fd7233df6da89db5e8ab944585/src/HttpClientFactory/Polly/src/PolicyHttpMessageHandler.cs#L109), which is registered by the `AddPolicyHandler`, then you can see that it will use the existing Context and will create a new one only if there wasn't any. – Peter Csala Oct 06 '22 at 13:48
  • With that being said it is still possible that my understanding is wrong and the lifecycle of the Context is tightly coupled to the `HttpRequestMessage`. – Peter Csala Oct 06 '22 at 13:49
  • I've put together a demo, where I have another policy which triggers for 429. [I've recorded my debugging](https://drive.google.com/file/d/1jJb_w0mKyD08ztt2WAcRh8iohopbpmIH/view?usp=sharing) and it seems like the Context exists during all retry attempts. – Peter Csala Oct 06 '22 at 14:25
  • @PeterCsala - I don't understand why you need the complication of the 'context'. What is the down-side to having the `SendAsync` method access the `AcessToken` directly from the `ITokenService`? – Quark Soup Oct 09 '22 at 14:01
  • The usage of context is not essential from the solution perspective. It is an optimization for those kind of retries which are not triggered due to expired token. – Peter Csala Oct 09 '22 at 14:49