Comment faire face à un réseau souvent instable en mobilité ?

Si elle n’est pas stable, la connexion réseau sur le mobile peut s’avérer être une problématique importante à connaître. Plusieurs problèmes sont apparus avec le mobile et constituent des challenges techniques à relever.
Il n’est pas rare qu’une API ne soit pas accessible sur un réseau mobile alors que celle-ci fonctionne très bien. Changer de connectivité 3G/4G à un wifi interne vous fera perdre internet pendant quelques millisecondes. Ces quelques millisecondes sont suffisantes pour vous empêcher d’accéder à vos ressources en ligne.
Imaginez qu’un utilisateur de votre application mobile appelle une API externe permettant le bon fonctionnement de votre application. S’il est dans le cas précédent (perte de connexion), il y a de fortes chances que l’action de l’utilisateur n’ait pas fonctionné. Cela peut aussi jouer à terme sur la qualité de votre application, alors que seule sa connectivité est en jeu. Il suffirait de refaire un ou deux appels tout de suite après le premier pour résoudre ce problème.
Le but de cet article est d’écrire ensemble une méthode générique permettant de résoudre la problématique expliquée.
Faire face à un réseau instable : première approche
J’ai toujours eu l’habitude lorsque j’avais des problématiques comme celle-ci d’y aller petit pas par petit pas, puis de l’améliorer au mieux.
Si je résume ce que nous devons faire :
- Boucle sur un await (nous voulons faire jusqu’à 3 essais. Plus ne me paraît pas intéressant car il y a de fortes chances qu’il ait vraiment un problème de réseau).
- Attendre le retour ou attendre l’Exception
- Incrémenter mon Counter pour ne pas boucler à l’infini
- Et surtout si mon await est en SUCCESS, sortir de ma boucle.
const int maximumTries = 2; bool isSuccess = false; int counter = 1; HttpResponseMessage swapi; do { try { swapi = await new HttpClient().GetAsync("swapi.co/api/planets/1/"); isSuccess = true; } catch (Exception ex) { if (counter < maximumTries){ Debug.WriteLine("I’m gonna retry again, I’m sure I can succeed: ", ex.Message); } else{ Debug.WriteLine("I will stop being stubborn: " + ex.Message); } } finally { if (isSuccess){ Debug.WriteLine("I knew I could do it : "); } } } while (!isSuccess && counter++ < maximumTries);
Et voilà, avec ce code nous avons ce que nous souhaitons. Pour résumer, j’ai juste rajouté autour de ma méthode Async, une simple boucle qui répète le code.
Mais en réalité, ce n’est pas ce code qui va nous permettre de générer une solution viable. Il y a beaucoup de réécriture de code à faire sur chaque utilisation d’une méthode Async.
L’approche Task
Pour créer notre méthode générique, j’ai trouvé plus facile de travailler directement avec des Tasks plutôt que d’utiliser des Awaits qui cachent eux-mêmes des Tasks.
La méthode en entrée sera simplement une Fonction de Task à exécuter qui retournera donc une Task. Et pour écrire un peu moins de code, nous allons faire une méthode récursive nous permettant de faire appel à cette même fonction tant que le nombre d’essais n’est pas dépassé.
public Task<T> RunTaskWithRetry<T>(Func<Task<T>> func) {}
Pour cette méthode nous allons avoir besoin :
TaskCompletionSource :
L’objet TaskCompletionSource va tout simplement encapsuler une Task sur laquelle il sera possible d’effectuer énormément de traitements supplémentaires. TaskCompletionSource va nous permettre d’affecter une continuation à cette Task passée en paramètre. Elle va également nous permettre de maîtriser le démarrage de cette continuation en affectant un état final à notre Task encapsulée via certaines méthodes (tcs.SetException , tcs.SetResult).
IEnumerable
Lorsque nous regardons la méthode SetException, nous remarquons qu’elle a besoin d’un IEnumerable.
Pour rappel, le but de cette méthode est de réessayer plusieurs fois l’appel si celle-ci échoue, donc si celle-ci nous renvoie une Exception.
Cœur de la méthode :
if (/*Task précédente qui à échoué*/) { var exceptions = VARIABLE1.Concat( (/*Task précédente qui à échoué*/).Exception.Flatten().InnerExceptions); if (counter > 0){ RunTaskWithRetry(func, counter - 1, tcs, exceptions); } else{ tcs.SetException(exceptions); } } else tcs.SetResult(VARIABLE2.Result);
VARIABLE1 : L’Exception précédente de ma dernière tâche.
VARIABLE2 : le résultat de ma tâche précédente.
Je vous invite avoir la doc de microsoft IException.Flatten().InnerExceptions pour l’utilisation de cette méthode. Mais en résumé, ça nous permettra juste de nous retourner l’exception d’origine.
Je m’aperçois vite que la signature de base que j’avais créé ne va pas être suffisante.
J’ai besoin d’envoyer à la méthode :
- Ma Task
- L’Exception reçue par ma Task échouante.
- Ma TaskCompletionSource
- Et mon Counter que je vais décrémenter de 1 chaque fois que je fais la récursivité.
Du coup, au premier appel de ma fonction je vais avoir des différences. Je vais donc créer deux signatures : une publique et une privée.
public static Task<T> RunTaskWithRetry<T>(Func<Task<T>> func, int counter = 3) { return RunTaskWithRetryInternal(func, counter - 1, new TaskCompletionSource<T>(), Enumerable.Empty<Exception>()); } static Task<T> RunTaskWithRetryInternal<T>( Func<Task<T>> func, int counter, TaskCompletionSource<T> tcs, IEnumerable<Exception> previousEx) {}
Nous avons maintenant toutes les informations pour remplir les variables 1 et 2. Voici ce que donne l’implémentation de cette fonction.
static Task<T> RunTaskWithRetryInternal<T>( Func<Task<T>> func, int counter, TaskCompletionSource<T> tcs, IEnumerable<Exception> previousEx) { func().ContinueWith(previousTask => { if (previousTask.IsFaulted) { var exceptions = previousEx.Concat( previousTask.Exception.Flatten().InnerExceptions); if (counter > 0){ RunTaskWithRetryInternal(func, counter - 1, } tcs, exceptions, configureAwait); else{ tcs.SetException(exceptions); } } else{ tcs.SetResult(previousTask.Result); } }); return tcs.Task; }
Première amélioration : du synchrone dans notre enchaînement
Même si cette méthode est maintenant fonctionnelle, elle ne va pas forcément être efficace. Nous souhaitons que notre Task Async soit asynchrone, cependant, nous voulons que l’enchaînement des appels soit lui, synchrone. Pourquoi ?
Car le but est de réessayer le premier appel si celui-ci a échoué et seulement s’il a échoué. Nous ne voulons pas paralléliser ces tâches pour retomber dans notre cas initial.
Il suffit donc sur notre Lambda de rajouter un :
func().ContinueWith(previousTask => {}, TaskContinuationOptions.ExecuteSynchronously)
Deuxième amélioration : la gestion du contexte
Il est important de savoir la gestion du contexte dans des Tasks Aync.
Pour faire simple, une Task asynchrone capture automatiquement son contexte depuis son appel d’origine. Ceci lui permet, dans un Thread UI, de savoir exactement ou il se trouve lorsqu’il a fini sa Task. Cependant, si vous vous trouvez dans un service de votre ViewModel, ceci n’a aucun intérêt de capturer le contexte. Cela ne fera que ralentir les performances de votre requête, ce qui n’est pas souhaité.
func().ContinueWith().ConfigureAwait(false);
Troisième amélioration : la gestion de notre Task
Beaucoup de programmateurs lancent des tâches sans se préoccuper de leurs gestions une fois lancées.
Il est important de savoir qu’une tâche peut être annulée pour plusieurs raisons. Imaginez que l’utilisateur charge une View qui doit faire appel à une API en mode Aync. Si l’application est bien faite, la page, donc l’UI, ne sera pas bloquée même si celle-ci n’a pas fini de télécharger les données souhaitées.
Maintenant, imaginez que l’utilisateur souhaite revenir en arrière alors que votre appel à votre API n’est pas fini. Si vous n’avez pas de gestion de tâches, vous n’allez pas pouvoir annuler la tâche précédemment exécutée. L’utilisateur de votre application utilisera donc de la ressource, de la data, ainsi que de la batterie pour des données qui ne seront jamais utilisées.
Le but ici est d’implémenter une fonction avec en paramètre un Token provenant d’une TaskCompletionSource si celle-ci existe.
Pour la créer, nous utiliserons :
var cts = new CancellationTokenSource(); cts.Token;
cts.Token nous permettra de récupérer le Token qui sera envoyé à notre Func en paramètre de notre méthode. Il nous faudra donc changer nos signatures pour accepter une CancellationToken.
Voici après nos trois améliorations ce à quoi correspondrait notre code :
using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net.Http; using System.Threading.Tasks; namespace Cellenza { public class TaskHelper { static Task<T> RunTaskWithRetryInternal<T>( Func<Task<T>> func, int counter, TaskCompletionSource<T> tcs, IEnumerable<Exception> previousExceptions, bool configureAwait) { func().ContinueWith(previousTask => { if (previousTask.IsFaulted) { var exceptions = previousExceptions.Concat( previousTask.Exception.Flatten().InnerExceptions); if (counter > 0){ RunTaskWithRetryInternal(func, counter - 1, } tcs, exceptions, configureAwait); else{ tcs.SetException(exceptions); } } else{ tcs.SetResult(previousTask.Result); } }, TaskContinuationOptions.ExecuteSynchronously).ConfigureAwait(configureAwait); return tcs.Task; } public static Task<T> RunTaskWithRetry<T>(Func<Task<T>> func, bool configureAwait = false, int counter = 3) { return RunTaskWithRetryInternal(func, counter, new TaskCompletionSource<T>(), Enumerable.Empty<Exception>(), configureAwait); } } }
Maintenant pour utiliser notre méthode, rien de plus simple. Il suffit de l’encapsuler sur chacune de vos méthodes Aync avec le code suivant :
await TaskHelper.RunTaskWithRetry(() => new HttpClient().GetAsync("swapi.co/api/planets/1/"));
N’hésitez pas à utiliser les arguments nommés pour appeler notre méthode avec des paramètres autres que ceux par défauts et dans un ordre non normé par la fonction.
await TaskHelper.RunTaskWithRetry(counter:5, func: () => new HttpClient().GetAsync("swapi.co/api/planets/1/"));
J’espère que cet article vous a plu. N’hésitez pas à partager en commentaire vos remarques !
Merci, article intéressant.
Pour aller plus loin, ça peut être une bonne idée d’aller voir comment ce type de pattern est implémenté dans des librairies spécialisées comme Polly https://github.com/App-vNext/Polly
Bonjour,
Je suis content de voir que mon article vous a plus. Au sein de cet article, j’ai voulu donner une logique pour résoudre une des problématiques via la compréhension des Tasks.
Polly est effectivement une librairie qui fait ses preuves depuis quelques années et fait très bien le travail pour refaire des appels automatiques et gérer les Exceptions. (Attention tout de même de ne pas avoir un code géré que par des Exceptions. Catcher des Exceptions reste lourd en C#) L’utilisation de Refit, Akavache et Polly au sein d’une application peut faire beaucoup de bien à une application mobile.
A très vite.