Code Monkey home page Code Monkey logo

Comments (15)

LeoJHarris avatar LeoJHarris commented on September 28, 2024 1

@peter-csala sorry for my own delay in responding, many tasks I am across and put this issue back a bit but I will check this out shortly and get back to you if I can find further information on this. Will be in touch shortly.

from polly.

LeoJHarris avatar LeoJHarris commented on September 28, 2024 1

I will try get a sample application shortly. Thank you!

from polly.

LeoJHarris avatar LeoJHarris commented on September 28, 2024 1

@peter-csala this might take some time to get you a sample application, but Ill endeavor to get this ASAP. Keen to get this sorted.

from polly.

peter-csala avatar peter-csala commented on September 28, 2024

Hi Leonard, I've provided three slightly different implementation examples here how to solve refresh token problem with Polly.

Similar question as your has been asked at end of last year: how to make sure that only a single refresh request is sent out. Here I have provided a sample for that (basically a SemaphoreSlim and some simple heuristics).

IMHO my suggested solution is a bit more convenient than passing around the HttpClient as a part of the Context.

from polly.

LeoJHarris avatar LeoJHarris commented on September 28, 2024

@peter-csala thank you for the solution provided!

I'm guessing in my user case I will still need the HttpClient as part of the Context for the sake of updating the headers with a new token.

i.e.:

HttpClient httpClient = (HttpClient)context["httpClient"];

if (await localStorageService.GetSecurityTokenAsync().ConfigureAwait(false) is string securityToken 
&& !string.IsNullOrEmpty(securityToken))
                {
                    if (httpClient.DefaultRequestHeaders.Contains(Constants.AuthenticationTokenHeaderKey))
                    {
                        _ = httpClient.DefaultRequestHeaders.Remove(Constants.AuthenticationTokenHeaderKey);
                    }

                    httpClient.DefaultRequestHeaders.Add(Constants.AuthenticationTokenHeaderKey, securityToken);
                }

On next retry the HttpClient will contain the new header with token?

from polly.

peter-csala avatar peter-csala commented on September 28, 2024

On next retry the HttpClient will contain the new header with token?

The presented code itself yes it updates the related header in a way that the next attempt could read the refreshed token.

But because it is just a code fragment that's why I can not say that it solves your original question/problem. Your header update logic is not atomic so, multiple threads can update the header simultaneously if it is not treated as a critical section (by protecting with a lock).

As with my suggested sample make sure only one thread can perform the token refresh at the same time.

from polly.

LeoJHarris avatar LeoJHarris commented on September 28, 2024

@peter-csala thank you for your quick response 👍

The related code is the following:

 public static AsyncRetryPolicy<HttpResponseMessage> AuthEnsuringPolicy = Policy<HttpResponseMessage>
        .HandleResult(r => r.StatusCode == HttpStatusCode.Unauthorized)
        .RetryAsync(1, onRetryAsync: async (ex, i, context) => _ = await App.ContainerProvider.Resolve<IAuthTokenService>().RefreshTokenAsync((HttpClient)context["httpClient"]).ConfigureAwait(true));

The RefreshTokenAsync method is this, but basically RefreshAuthorizationTokenAsync will update local storage on the SecurityToken so that GetSecurityTokenAsync will have latest;

public class AuthTokenService : IAuthTokenService
{
    private static readonly SemaphoreSlim _semaphoreSlim = new(1);

    private DateTime? _lastRefreshed;

    public async Task<bool> RefreshTokenAsync(HttpClient httpClient)
    {
        await _semaphoreSlim.WaitAsync().ConfigureAwait(false);

        try
        {
            //Use any arbitrary logic to detect simultaneous calls
            if (_lastRefreshed.HasValue && _lastRefreshed - DateTime.UtcNow < TimeSpan.FromSeconds(3))
            {
                Console.WriteLine("No refreshment happened");
                return false;
            }

            ILocalStorageService localStorageService = App.ContainerProvider.Resolve<ILocalStorageService>();

            if (localStorageService.GetCustomer() is Customer customer 
&& await customer.GetPasswordAsync().ConfigureAwait(true) is string password
                && await App.ContainerProvider.Resolve<IUserManagementService>().RefreshAuthorizationTokenAsync(customer.Mobile, password).ConfigureAwait(true) is not null 
&& await localStorageService.GetSecurityTokenAsync().ConfigureAwait(false) is string securityToken
                    && !string.IsNullOrEmpty(securityToken))
            {
                if (httpClient.DefaultRequestHeaders.Contains(Constants.AuthenticationTokenHeaderKey))
                {
                    _ = httpClient.DefaultRequestHeaders.Remove(Constants.AuthenticationTokenHeaderKey);
                }

                httpClient.DefaultRequestHeaders.Add(Constants.AuthenticationTokenHeaderKey, securityToken);

                Debug.WriteLine($"Refreshment happened {DateTime.UtcNow}");
                _lastRefreshed = DateTime.UtcNow;
            }
        }
        finally
        {
            _ = _semaphoreSlim.Release();
        }

        return false;
    }
}

The httpClient.Send section:

requestMessage.SetPolicyExecutionContext(new Context
            {
                { "retrycount", 0 },
                 { "httpClient", httpClient }
        });

        HttpResponseMessage httpResponseMessage = await httpClient.SendAsync(requestMessage).ConfigureAwait(false);

        using Stream stream = await httpResponseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false);
        using StreamReader steamReader = new(stream);
        using JsonTextReader jsonTextReader = new(steamReader);

        if (httpResponseMessage.IsSuccessStatusCode)
        {
            return new MemoryStream(buffer: stream.ToByteArray());
        }

Just a side question as well, on the RefreshTokenAsync is there any reason why the SemaphoreSlim cant be changed to private static readonly SemaphoreSlim _semaphoreSlim = new(1, 1); to give it max count? Then the following could be removed that only permit one execution at a time

if (_lastRefreshed.HasValue && _lastRefreshed - DateTime.UtcNow < TimeSpan.FromSeconds(3))
            {
                Console.WriteLine("No refreshment happened");
                return false;
            }

from polly.

peter-csala avatar peter-csala commented on September 28, 2024

@LeoJHarris According to my understanding there both of these means "exclusive lock"

private static readonly SemaphoreSlim _semaphoreSlim = new(1);

and

private static readonly SemaphoreSlim _semaphoreSlim = new(1, 1);

When you acquire a token the semaphore is decrementing its counter. So, if we would call the Release more times than the WaitAsync then the counter could go higher than 1.

Here is a dotnet fiddle to play with it: https://dotnetfiddle.net/o6vEk2

from polly.

LeoJHarris avatar LeoJHarris commented on September 28, 2024

@peter-csala Hi have found the lock to be working correctly, no issues there, however the following seems to be happening when attempting to refresh the token and make subsequent calls again on retry:

First time:

[0:] Did Login 3/13/2024 10:44:28 PM
[0:] Refreshment happened 3/13/2024 10:44:28 PM
[0:] No refreshment happened 3/13/2024 10:44:28 PM
[0:] No refreshment happened 3/13/2024 10:44:28 PM
[0:] No refreshment happened 3/13/2024 10:44:28 PM
[0:] No refreshment happened 3/13/2024 10:44:28 PM
[0:] No refreshment happened 3/13/2024 10:44:28 PM
[0:] No refreshment happened 3/13/2024 10:44:28 PM

Later on this is called again shortly after on retry:

[0:] Did Login 3/13/2024 10:44:40 PM
[0:] Refreshment happened 3/13/2024 10:44:40 PM
[0:] No refreshment happened 3/13/2024 10:44:40 PM
...

I have updated the retryAttempt to 15 seconds to give further time to retrieve a new refresh token:

public static AsyncRetryPolicy<HttpResponseMessage> AuthEnsuringPolicy = Policy
    .Handle<HttpRequestException>()
    .OrResult<HttpResponseMessage>(resp => resp.StatusCode == HttpStatusCode.Unauthorized)
    .WaitAndRetryAsync(3,
        retryAttempt => TimeSpan.FromSeconds(15),
        onRetry: async (resp, timeSpan, context) => _ = await App.ContainerProvider.Resolve<IAuthTokenService>().RefreshTokenAsync((HttpClient)context["httpClient"]).ConfigureAwait(true));
}

I could be wrong but from my testing it would seem that when setting httpClient.DefaultRequestHeaders.Add(Constants.AuthenticationTokenHeaderKey, securityToken); on the RefreshTokenAsync that subsequently calls do not have the updated token? Hence it attempts the login again but it should actually be completing the initial request as the token was refreshed.

from polly.

peter-csala avatar peter-csala commented on September 28, 2024

@LeoJHarris

Sorry for the late response I was away from keyboard for several days.

I've tried to reproduce your problem with and without Polly.

Without Polly

private const string ClientName = "TestClient";
private const string HeaderKey = "AuthToken";
public static async Task Main()
{
	var collection = new ServiceCollection();
	collection.AddHttpClient(ClientName, (sp, client) =>
	{
		client.DefaultRequestHeaders.Add(HeaderKey, GetToken());
	});
	var provider = collection.BuildServiceProvider();
	
	//Simulate retry
	for(int i = 0; i < 5; i++)
	{
		Test(provider);
		await Task.Delay(Random.Shared.Next(1000));
	}
}

public static void Test(IServiceProvider sp)
{
	var factory = sp.GetRequiredService<IHttpClientFactory>();
	
	var client = factory.CreateClient(ClientName);
	
	var token = client.DefaultRequestHeaders.GetValues(HeaderKey).First();
	
	Console.WriteLine($"Token: {token}");
	
	//Simulate token refresh
	client.DefaultRequestHeaders.Remove(HeaderKey);
	client.DefaultRequestHeaders.Add(HeaderKey, GetToken());
}

private static string GetToken() => DateTime.UtcNow.TimeOfDay.ToString();

And it correctly updates the header. See the related dotnet fiddle.

With Polly

private const string ClientName = "TestClient";
private const string HeaderKey = "AuthToken";
private const string ContextKey = "HttpClient";
public static async Task Main()
{
	var collection = new ServiceCollection();
	collection.AddHttpClient(ClientName, (sp, client) =>
	{
		client.DefaultRequestHeaders.Add(HeaderKey, GetToken());
	});
	var provider = collection.BuildServiceProvider();
	
	var context = new Polly.Context();
	var factory = provider.GetRequiredService<IHttpClientFactory>();
	var client = factory.CreateClient(ClientName);
	context[ContextKey] = client;
	
	try
	{
		await GetRetry().ExecuteAsync(ctx => Test(), context);
	}
	catch(Exception)
	{
		Console.WriteLine("Final retry failed as well");
	}
		
}

public static Task Test() => Task.FromException(new Exception("Damn"));

private static string GetToken() => DateTime.UtcNow.TimeOfDay.ToString();

public static IAsyncPolicy GetRetry() =>
	Policy
	.Handle<Exception>()
	.WaitAndRetryAsync(4,
		retryAttempt => TimeSpan.FromMilliseconds(Random.Shared.Next(1000)),
		onRetry: (ex, ts, ctx) => {
			var client = ctx[ContextKey] as HttpClient;
			var token = client.DefaultRequestHeaders.GetValues(HeaderKey).First();
			Console.WriteLine($"Token: {token}");
			
			client.DefaultRequestHeaders.Remove(HeaderKey);
			client.DefaultRequestHeaders.Add(HeaderKey, GetToken());
		});

And it also correctly updates the header. See the related dotnet fiddle.


So, there must be something else which is not shared in this thread.

from polly.

LeoJHarris avatar LeoJHarris commented on September 28, 2024

@peter-csala again thank you for your continued assistance on this issue 🙏 hopefully the following can provide a more complete picture of what my code is doing but somethings have been deliberately omitted for brevity, I don't think there is anything outside these code blocks further I can add that would impact the ability to refresh the token for future calls:

App.xaml.cs

Sets up the HttpClient with the Service Collection for DI registration including taking advantage of the Polly FallbackAsync

IAsyncPolicy<HttpResponseMessage> wrapOfRetryAndFallback =
Policy.WrapAsync(Policy.HandleResult<HttpResponseMessage>(r =>
!r.IsSuccessStatusCode).FallbackAsync(fallbackActionAsync, onFallBackAsync), PollyPolicies.AuthEnsuringPolicy);

_ = containerRegistry.RegisterSingleton<IHttpClientFactory>(() => 
new ServiceCollection().AddHttpClient(Constants.HttpClientWithRetry).AddPolicyHandler(wrapOfRetryAndFallback).Services.BuildServiceProvider().GetService<IHttpClientFactory>());

ApiService.cs

Following is where the initial request is called from such as LoginCustomerAsync
Example request to login customer

async Task<IdentityToken?> IApiService.LoginCustomerAsync(Login login)
    {
        StringContent stringContent = new(login.ToJson(), Encoding.UTF8, "application/json");

        MemoryStream? result =
           await executeRestRequestAsync(new Uri(string.Format("{0}/Access/Login?...."))), HttpMethod.Post, stringContent, requiresAuthenticationTokenHeader: false)
           .ConfigureAwait(false);

        if (result is not null)
        {
            DataContractJsonSerializer js = new(typeof(IdentityToken));
            return (IdentityToken?)js.ReadObject(result);
        }

        throw new Exception("HTTP returned null");
    }

Method that handles each request, this is where I retrieve the Security Token if it exists will be used as the header & the Polly Context takes the HttpClient here as well:

 private async Task<MemoryStream?> executeRestRequestAsync(Uri uri, HttpMethod httpMethod, StringContent? stringContent = null, bool requiresAuthenticationTokenHeader = true)
    {
        HttpClient httpClient = _httpClientFactory.CreateClient(Constants.HttpClientWithRetry);

        // Only add security key if empty and not null (will be null on first login customer
        // required on that request)
        if (await _localStorageService.GetSecurityTokenAsync().ConfigureAwait(false) is string securityToken && !string.IsNullOrEmpty(securityToken) && requiresAuthenticationTokenHeader)
        {
            if (httpClient.DefaultRequestHeaders.Contains(Constants.AuthenticationTokenHeaderKey))
            {
                _ = httpClient.DefaultRequestHeaders.Remove(Constants.AuthenticationTokenHeaderKey);
            }

            httpClient.DefaultRequestHeaders.Add(Constants.AuthenticationTokenHeaderKey, securityToken);
        }

        HttpRequestMessage? requestMessage = null;

        // Check the HTTP method enum to determine the HTTP method to call
        switch (httpMethod)
        {
            case HttpMethod.Get:

                requestMessage = new(System.Net.Http.HttpMethod.Get, uri);

                break;

            case HttpMethod.Post:

                requestMessage = new(System.Net.Http.HttpMethod.Post, uri)
                {
                    Content = stringContent
                };

                break;

            case HttpMethod.Put:

                requestMessage = new(System.Net.Http.HttpMethod.Put, uri)
                {
                    Content = stringContent
                };

                break;

            case HttpMethod.Delete:

                requestMessage = new(System.Net.Http.HttpMethod.Delete, uri);
                break;
        }

        requestMessage.SetPolicyExecutionContext(new Context
        {
            { "retrycount", 2 },
            { "httpClient", httpClient }
        });

        HttpResponseMessage httpResponseMessage = await httpClient.SendAsync(requestMessage).ConfigureAwait(true);

        using Stream stream = await httpResponseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false);
        using StreamReader steamReader = new(stream);
        using JsonTextReader jsonTextReader = new(steamReader);

        if (httpResponseMessage.IsSuccessStatusCode)
        {
            return new MemoryStream(buffer: stream.ToByteArray());
        }
...

PollyPolicies.cs

The retry policy will take the HttpClient from the context and some checks on the customer signed in status, if refresh token is needs refreshing then the HttpClient will be passed to the RefreshTokenAsync method to update the header whilst also updating the locally stored security token

public static AsyncRetryPolicy<HttpResponseMessage> AuthEnsuringPolicy = Policy
    .Handle<HttpRequestException>()
    .OrResult<HttpResponseMessage>(resp => resp.StatusCode == HttpStatusCode.Unauthorized && App.ContainerProvider.Resolve<ILocalStorageService>().GetStaySignedIn())
    .WaitAndRetryAsync(3,
         retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
        onRetry: async (resp, timeSpan, context) =>
        {
            if (context["httpClient"] is HttpClient httpClient
&& App.ContainerProvider.Resolve<ILocalStorageService>().GetCustomer() is Customer customer && !customer.IsLoggedIn
&& customer.StaySignedIn)
            {
                _ = await App.ContainerProvider.Resolve<IAuthTokenService>().RefreshTokenAsync(httpClient).ConfigureAwait(true);
            }
        });

AuthTokenService.cs

This appears to be working fine, the HttpClient gets passed further down the line and updates only once. The RefreshAuthorizationTokenAsync will re sign in a customer using the locally stored password and username to retrieve new token.

public class AuthTokenService : IAuthTokenService
{
    private static readonly SemaphoreSlim _semaphoreSlim = new(1);

    private static DateTime? _lastRefreshed;

    public async Task<bool> RefreshTokenAsync(HttpClient httpClient)
    {
        await _semaphoreSlim.WaitAsync().ConfigureAwait(true);

        try
        {
            if (!_lastRefreshed.HasValue)
            {
                await UpdateAuthenticationTokenHeaderAsync(httpClient).ConfigureAwait(true);
            }
            else if ((_lastRefreshed.Value - DateTime.UtcNow).Duration() < TimeSpan.FromSeconds(5).Duration())
            {
                Debug.WriteLine($"No refreshment happened {DateTime.UtcNow}");
                return false;
            }
            else
            {
                await UpdateAuthenticationTokenHeaderAsync(httpClient).ConfigureAwait(true);
            }
        }
        finally
        {
            _ = _semaphoreSlim.Release();
        }

        return false;
    }

    private static async Task UpdateAuthenticationTokenHeaderAsync(HttpClient httpClient)
    {
        ILocalStorageService localStorageService = App.ContainerProvider.Resolve<ILocalStorageService>();

        if (localStorageService.GetCustomer() is Customer customer
            && await customer.GetPasswordAsync().ConfigureAwait(true) is string password
            && await App.ContainerProvider.Resolve<IUserManagementService>().RefreshAuthorizationTokenAsync(customer.Mobile, password).ConfigureAwait(true) is not null)
        {
            if (httpClient.DefaultRequestHeaders.Contains(Constants.AuthenticationTokenHeaderKey))
            {
                _ = httpClient.DefaultRequestHeaders.Remove(Constants.AuthenticationTokenHeaderKey);
            }

            if (await localStorageService.GetSecurityTokenAsync().ConfigureAwait(false) is string securityToken
                && !string.IsNullOrEmpty(securityToken))
            {
                httpClient.DefaultRequestHeaders.Add(Constants.AuthenticationTokenHeaderKey, securityToken);
            }

            Debug.WriteLine($"Refreshment happened {DateTime.UtcNow}");
            _lastRefreshed = DateTime.UtcNow;
        }
    }
}

UserManagementService.cs

The end routine lays within the RefreshAuthorizationTokenAsync method that should do the re-login for a customer and update the locally stored SecurityToken that will be used for subsequent API calls. When this completes then in the previous code block the headers will receive the new token that was persisted locally.

 async Task<long?> IUserManagementService.RefreshAuthorizationTokenAsync(string mobileNumber, string password)
    {
        IdentityToken? identityToken = await Policy<IdentityToken?>.Handle<Exception>().FallbackAsync(async (outcome, context, ct) =>
        {
...
        }

            return outcome.Result;
        }, (ct, cx) => Task.CompletedTask).ExecuteAsync(async () => await _apiService.LoginCustomerAsync(new Login
        {
            Password = password,
            Username = mobileNumber
        }).ConfigureAwait(true)).ConfigureAwait(true);

        if (identityToken is not null)
        {
            bool isCustomer = false;
            string? identityId = string.Empty;

            if (!identityToken.Claims.Any())
            {
                ...
            }
            else
            {
                foreach (IdentityTokenClaim c in identityToken.Claims)
                {
                    if (string.Compare(c.ClaimType, Constants.RoleClaim) == 0)
                    {
                        isCustomer = string.Compare(c.ClaimValue, "Customer", true) == 0;
                    }

                    if (string.Compare(c.ClaimType, Constants.NameIdentifierClaim) == 0)
                    {
                        identityId = c.ClaimValue;
                    }
                }

                if (isCustomer && identityId is not null && long.TryParse(identityId, out long customerId))
                {
                    await _localStorageService.SetAuthTokenAsync(identityToken.SecurityToken).ConfigureAwait(true);
                    _localStorageService.SetSecurityTokenExpiry(identityToken.Expires);

                    return customerId;
                }
                else
                {
                    ...
                }
            }
        }

        return null;
    }

Let me know if you can see something that might be causing issues but from the

from polly.

peter-csala avatar peter-csala commented on September 28, 2024

@LeoJHarris Could you please create a sample github repo with the above code fragments?

Then I could play with it on my machine to better understand the data and control flow.


UPDATE 1

I tried to extract relevant code fragments from your post. Mainly decorating the HttpClient with the retry policy and passing the Context object through the HttpRequestMessage. Related dotnet fiddle.

public static async Task Main()
{
	var collection = new ServiceCollection();
	collection.AddHttpClient(ClientName, (sp, client) =>
	{
		client.DefaultRequestHeaders.Add(HeaderKey, GetToken());
	})
	.AddPolicyHandler(GetRetry());
	var provider = collection.BuildServiceProvider();
	
	var context = new Polly.Context();
	var factory = provider.GetRequiredService<IHttpClientFactory>();
	var client = factory.CreateClient(ClientName);
	context[ContextKey] = client;
	HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Get, "http://httpstat.us/500");
	message.SetPolicyExecutionContext(context);
	
	try
	{
		var response = await client.SendAsync(message);
		response.EnsureSuccessStatusCode(); //throws HttpRequestException
	}
	catch(Exception)
	{
		Console.WriteLine("Final retry failed as well");
	}		
}
private static string GetToken() => DateTime.UtcNow.TimeOfDay.ToString();

private static IAsyncPolicy<HttpResponseMessage> GetRetry() =>
	Policy<HttpResponseMessage>
	.Handle<HttpRequestException>()
	.OrResult(res => !res.IsSuccessStatusCode)
	.WaitAndRetryAsync(4,
		retryAttempt => TimeSpan.FromMilliseconds(Random.Shared.Next(1000)),
		onRetry: (ex, ts, ctx) => {
			var client = ctx[ContextKey] as HttpClient;
			var token = client.DefaultRequestHeaders.GetValues(HeaderKey).First();
			Console.WriteLine($"Token: {token}");
			
			client.DefaultRequestHeaders.Remove(HeaderKey);
			client.DefaultRequestHeaders.Add(HeaderKey, GetToken());
		});

It still works like a charm.

from polly.

peter-csala avatar peter-csala commented on September 28, 2024

@peter-csala this might take some time to get you a sample application, but Ill endeavor to get this ASAP. Keen to get this sorted.

Sure thing, no problem. Take your time.

from polly.

github-actions avatar github-actions commented on September 28, 2024

This issue is stale because it has been open for 60 days with no activity. It will be automatically closed in 14 days if no further updates are made.

from polly.

github-actions avatar github-actions commented on September 28, 2024

This issue was closed because it has been inactive for 14 days since being marked as stale.

from polly.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.