Polly per gestire la scadenza di un token JWT

Ciao a tutti, in questo articolo voglio parlavi di come ho risolto la gestione di un token JWT durante un progetto di integrazione con un provider di servizi telefonici.

Contesto

Prima di tutto cerchiamo di capire il contesto: dovevo realizzare un’applicazione .NET 9 che dialogasse con un provider di servizi telefonici. Questo provider esponeva delle api REST autenticate tramite un token JWT. Il token aveva le seguenti caratteristiche:

  • Non possedeva nessuna informazione inerente alla sua scadenza
  • La scadenza era decisa dal provider, il quale mi metteva a disposizione un endpoint per verificare se il token fosse ancora valido
  • Il provider poteva revocare il token in qualsiasi momento, in questo caso avrei dovuto effettuare una nuova chiamata API di autenticazione ed ottenere un nuovo token

Oltre a questo c’è da considerare che l’applicazione che dovevo realizzare era sottoposta ad un grande carico e non era quindi possibile andare ad effettuare una chiamata per validare il token, sia per una questione di performance sia per una questione di numeri di chiamate.

Workflow

In questo articolo ci soffermeremo solamente sulle parti principali che ho utilizzato per risolvere la gestione del token.

Quello che vogliamo andare a costruire segue questo schema:

Definizione di una policy

Come prima cosa ho installato la libreria Polly, per chi non la conoscesse è una libreria .NET che semplifica l’implementazione di politiche di resilienza come retry, circuit-breaker, timeout e fallback. Tramite questa libreria sarà molto semplice andare a costruire una politica per ritentare una chiamata che non è andata a buon fine. Per installarla è necessario aggiungere questi pacchetti al progetto:

C#
dotnet add package Polly
dotnet add package Polly.Extensions.Http

Nella classe dove dovremo andare ad utilizzare Polly ho creato una singola policy, applicata e utilizzata in tutti i metodi. Volendo si possono creare più policy, una per ogni caso o esigenza specifica.

C#
private const int RETRY_COUNT = 2;

//Define Policy
private static readonly AsyncRetryPolicy<HttpResponseMessage> retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult( r => 
  r.StatusCode != HttpStatusCode.OK && 
  r.StatusCode != HttpStatusCode.NoContent
  )
.Or<HttpRequestException>(ex => 
  ex.InnerException is TimeoutException
  )
.RetryAsync(
  RETRY_COUNT, 
  async (response, retryCount, context) =>
{
    //Retry implementation
});

La gestione del retry si attiverà se una di queste condizioni è soddisfatta:

.HandleTransientHttpError(): intercetta automaticamente errori transitori HTTP (es. 5xx o problemi di rete temporanei), tipici scenari in cui un retry può avere senso.

.OrResult( … ) alla riga 6: se la risposta è diverso da 200 OK oppure da NoContent (questo perché le api del provider potevano ritornare questi come esiti di chiamata positivi)

.Or(ex => ex.InnerException is TimeoutException ): Se va in timeout ci riproverà

Ora che abbiamo visto quali sono i criteri che faranno attivare la policy vediamo come ho implementato la logica all’interno di essa:

C#
private const int RETRY_COUNT = 2;

//Define Policy
private static readonly AsyncRetryPolicy<HttpResponseMessage> retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult( r => 
  r.StatusCode != HttpStatusCode.OK && 
  r.StatusCode != HttpStatusCode.NoContent
  )
.Or<HttpRequestException>(ex => 
  ex.InnerException is TimeoutException
  )
.RetryAsync(
  RETRY_COUNT, 
  async (response, retryCount, context) =>
{
    var integration = (PhoneServiceIntegration)context["phoneService"];

    //=============== Unauthorized =============== 
    if (response.Result?.StatusCode == HttpStatusCode.Unauthorized)
    {       
        //Clean token from cache
        integration.InvalidateToken();
        
        //Get new token and update it in header
        var token = await integration.GetTokenAsync();
        integration.AddOrUpdateHeaderToken(token);

    }
    //=============== Timeout =============== 
    else if (response.Exception is HttpRequestException && response.Exception.InnerException is TimeoutException)
    {
        integration._logger.Warning($"Timeout, retry number {retryCount}", "");
    }
    //=============== Other =============== 
    else
    {
        //manage other type of responses
    }

});

La classe dove sto utilizzando Polly si chiama PhoneServiceIntegration, alla riga 17 inietto all’interno della policy l’istanza del servizio, in modo tale da avere accesso ai metodi e ai servizi iniettati tramite Dependency Injection.

Dalla riga 20 alla riga 29 sono andato a gestire la casistica del token non valido o vuoto: tutte le volte che otterrò un errore 401 andrò ad invalidare il token e rigenerarlo.

Dalla riga 30 alla riga 34 è possibile andare a gestire un errore di tipo timeout. In questo caso verrà ritentata la chiamata fino a raggiungere il numero massimo di tentativi, che nel mio caso ho impostato a 2 (definito tramite la costante RETRY_COUNT)

Alla riga 36 è possibile gestire altre tipologie di errore, ovviamente questo dipende dal contesto e dalla vostra applicazione. Nel mio caso ho implementato alcune casistiche particolari definite con il provider di telefonia (ma che non riporto in questo esempio perchè non ci interessa ai fini dell’articolo)

Utilizzo della policy

Ora che abbiamo definito la nostra policy vediamo come andare ad utilizzarla. Il mio progetto prevedeva di essere utilizzato in contemporanea da un centinaio di utenti, la maggior parte dei quali eseguiva operazioni che prevedevano la chiamata di più endpoint del provider (GetQueues è un endpoint di esempio)

Per ottimizzare al meglio le risorse ho utilizzato l’interfaccia IClientFactory, iniettata direttamente nel costruttore:

C#
 public ComsyIntegration(
     IConfiguration config,
     ICache cache,
     IHttpClientFactory _clientFactory)
 {
     _cache = cache;
     _client = _clientFactory.CreateClient("PhoneProviderClient");
     _comsySettings = comsySettings;
 }

E definito nel Program.cs (nel mio caso in questo progetto poiché è un progetto API):

C#
services
    .AddHttpClient<ComsyIntegration>("PhoneProviderClient")
    .AddHttpMessageHandler<LoggingHandler>();

Ho quindi definito un metodo generico per ottenere l’istanza di un client http, recuperando il token e aggiungendolo nell’header:

C#
public async Task<HttpClient> GetHttpClientAsync()
{
    var token = await GetTokenAsync();
    AddOrUpdateComsyToken(token);
    return _client;
}

Il metodo GetTokenAsync() ricerca il token nelle cache, se manca allora va ad effettuare una chiamata API di autenticazione verso il provider.

Se il token fosse stato scaduto ci avrebbe pensato la policy che abbiamo configurato prima a rimuoverlo dalle cache e ri-effettuare una chiamata di autenticazione.

Il metodo AddOrUpdateComsyToken(token) aggiunge semplicemente il token nell’header.

A questo punto sono andato a realizzare una funzione generica per gestire i verbi GET, POST, PUT, DELETE. Vi riporto solamente l’esempio del metodo utilizzato per gestire le chiamate GET

C#
//Custom implementation for http GET
public async Task<T?> GetAsync<T>(string endpoint)
{
    //Build client
    var client = await GetHttpClientAsync();
    //Get response from endpoint
    using var response = await retryPolicy.ExecuteAsync(async (context) =>
        await client.GetAsync(endpoint)
        , new Context { ["phoneService"] = this });
        
    //Parse content and deserialize response
    string responseContent = await response.Content.ReadAsStringAsync();
    return JsonSerializer.Deserialize<T>(responseContent)
}

Vediamo di fare chiarezza sull’implementazione.

var client = await GetHttpClientAsync() richiama il metodo che ritorna un’istanza di HttpClient.

Dalla riga 13 alla riga 15 avviene la magia! Effettuiamo la chiamata http sfruttando la nostra policy. Da notare che che alla riga 14 c’è il metodo che effettivamente fa la chiamata http mentre alla riga 14 passiamo come parametro il contesto di questo servizio.

Nel momento in cui la chiamata alla riga 14 NON andasse a buon fine (secondo le logiche che abbiamo definito) allora scatterebbe il codice scritto all’interno della policy: retry in caso di timeout o generazione di un nuovo token in caso di Unauthorized.

Questo è il codice completo:

C#
    public partial class PhoneProviderIntegration: IPhoneProviderIntegration, IDisposable
    {
        private HttpClient _client;
        private readonly ICxLogger _logger;
        private readonly ICache _cache;
        
        //Retry counter if http call fail
        private const int RETRY_COUNT = 2;
        
        #region Polly Policy 
        
        //Define Policy
        private static readonly AsyncRetryPolicy<HttpResponseMessage> retryPolicy = HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult( r => 
          r.StatusCode != HttpStatusCode.OK && 
          r.StatusCode != HttpStatusCode.NoContent
          )
        .Or<HttpRequestException>(ex => 
          ex.InnerException is TimeoutException
          )
        .RetryAsync(
          RETRY_COUNT, 
          async (response, retryCount, context) =>
        {
            var integration = (PhoneServiceIntegration)context["phoneService"];
        
            //=============== Unauthorized =============== 
            if (response.Result?.StatusCode == HttpStatusCode.Unauthorized)
            {       
                //Clean token from cache
                integration.InvalidateToken();
                
                //Get new token and update it in header
                var token = await integration.GetTokenAsync();
                integration.AddOrUpdateHeaderToken(token);
        
            }
            //=============== Timeout =============== 
            else if (response.Exception is HttpRequestException && response.Exception.InnerException is TimeoutException)
            {
                integration._logger.Warning($"Timeout, retry number {retryCount}", "");
            }
            //=============== Other =============== 
            else
            {
                //manage other type of responses
            }
        
        });
        
        #endregion
        
        //Example method
        public async Task<List<QueueDTO>> GetQueuesAsync()
        {
            //Example of endpoint
            string endpoint = $"https://endpoint.example/api/v1/queues";
            //http get from endpoint
            var queues = await GetAsync<List<QueueDTO>>(endpoint);
            return queues;
        }
        
        #region Private
        
        //Prepare and get Http client
        public async Task<HttpClient> GetHttpClientAsync()
        {
            var token = await GetTokenAsync();
            AddOrUpdateComsyToken(token);
            return _client;
        }
        
        //Get token from cache or provider auth endpoint
        public async Task<string> GetTokenAsync()
        {
            ...
        }
        
        //Add Bearer JWT token to header
        public async Task AddOrUpdateComsyToken(string token)
        {
            ...
        }
        
        //Custom implementation for http GET
        public async Task<T?> GetAsync<T>(string endpoint)
        {
            //Build client
            var client = await GetHttpClientAsync();
            
            //Get response from endpoint
            using var response = await retryPolicy.ExecuteAsync(async (context) =>
                await client.GetAsync(endpoint)
                , new Context { ["phoneService"] = this });
                
            //Parse content and deserialize response
            string responseContent = await response.Content.ReadAsStringAsync();
            return JsonSerializer.Deserialize<T>(responseContent)
        }
        
        #endregion
    }

Conclusioni

In questo articolo vi ho mostrato un caso pratico e reale su come ho gestito la scadenza del token tramite la libreria Polly.

Appena avviata l’applicazione non avrà nessun token in cache e verrà quindi effettuata una chiamata API di autenticazione per ottenere un token valido. Nelle successive chiamate il token sarà sempre lo stesso fino a quando non scadrà (o verrà invalidato da parte del provider), in quel caso si attiverà la policy, rileverà l’errore 401, svuoterà le cache, effettuerà una nuova chiamata di autenticazione e ritenterà per la seconda volta la chiamata che prima non era andata a buon fine.

Il risultato sarà un’esecuzione pulita e lineare, senza l’aggiunta di codice particolare per gestire errori. Oltre a questo la policy si attiverà anche in caso di timeout o per errori 500, rendendo il nostro codice più resiliente agli errori. E’ anche possibile andare a configurare Polly affinché aumenti sempre di più il tempo di attesa tra un tentativo e l’altro (ma non l’ho usato in questo esempio, in caso vi invito a verificare sulla documentazione ufficiale)

    Spero che questo articolo vi sia piaciuto, alla prossima!

    Condividi questo articolo
    Shareable URL
    Post precedente

    .NET 9 Hybrid Cache

    Prosimo post

    Testa le API direttamente in Visual Studio!

    Lascia un commento

    Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

    Leggi il prossimo articolo