Quand on développe une application d’une certaine taille, on fait généralement appel à des solutions tierces pour certaines fonctionnalités qui ne sont pas “coeur de métier”, ou tout simplement pour des besoins annexes à l’application (CRM et compagnie).
Et si tout va bien, cette solution va nous fournir des API pour échanger avec elle.
Parfois ces API fonctionnent parfaitement, et tout le monde est content. Spoiler alert : c’est loin d’être toujours le cas !
Le but n’étant de taper sur personne, nous allons imaginer une API fictive, qui fonctionnera plus ou moins bien pour démontrer ce qui m’arrange.
Voilà ce qui va se passer :
On dispose d’une liste de villes pour lesquelles on a besoin de récupérer la météo.
On va aller de l’implémentation la plus simple à la plus robuste.
Si vous maîtrisez JavaScript, vous devriez connaître au moins jusqu’au niveau 2.
Mais j’ai bon espoir de vous faire découvrir des outils sympa dans la suite, restez jusqu’au bout (le 7ème va vous étonner).
Si vous voulez tester au fil de l’eau, tous les exemples sont disponibles ici : https://replit.com/@rsauget/blog-api
Niveau 0 – naïf
import _ from 'lodash'; async function getWeather({ city }) { // On ajoute un peu de délai pour se rendre compte de l'impact await new Promise((resolve) => setTimeout(resolve, 1000)); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); } const cities = ['Lyon', 'Montpellier', 'Paris']; const results = {}; for (const city of cities) { results[city] = await getWeather({ city }); } console.log(results); // { // Lyon: 'Soleil', // Montpellier: 'Soleil', // Paris: 'Pluie', // }
On a ici une première version très simple : on boucle sur les villes demandées, et pour chacune on va récupérer le temps qu’il y fait. On agrège les résultats dans un objet.
Dans un monde parfait, cette solution pourrait convenir.
En pratique, vous aurez surement noté plein de défauts : c’est bien le but, et d’ici quelques minutes j’espère que nous en aurons traité la majorité !
Le premier défaut de cette version naïve est d’effectuer une requête après l’autre, de façon séquentielle, comme si on avait besoin de la météo de la première ville avant de demander la météo de la seconde. On sent qu’il y a mieux à faire.
Version 1 – exécution “parallèle”
Le but n’étant pas de rentrer dans le détail de la gestion de l’asynchronicité en Javascript, vous me pardonnerez le raccourci de parler d’exécution parallèle.
On va donc utiliser un Promise.all
pour paralléliser les appels à notre service externe :
import _ from 'lodash'; async function getWeather({ city }) { await new Promise((resolve) => setTimeout(resolve, 1000)); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); } const cities = ['Lyon', 'Montpellier', 'Paris']; // On construit l'objet résultant à partir de paires [clé, valeur] const results = Object.fromEntries( await Promise.all( cities.map( async (city) => [city, await getWeather({ city })], ), ), ); console.log(results); // { // Lyon: 'Soleil', // Montpellier: 'Soleil', // Paris: 'Pluie', // }
Ca fonctionne bien parce qu’on a trois villes, et que notre API peut gérer 3 requêtes concurrentes les doigts dans le nez.
Essayez d’en mettre 1000 pour voir : il y a de bonnes chances que l’API vous dise gentiment de vous calmer.
Dans le meilleur des cas, ce sera une réponse HTTP cordiale : 429 - Too Many Requests
.
Mais ça peut passer par un timeout, une déconnexion impromptue (bonjour ECONNRESET
), une erreur 500
, ou même vous faire bloquer votre IP. Pas génial.
Pour pallier ce problème, on va limiter le nombre de requêtes concurrentes.
Version 2 – parallèle, mais pas trop
On pourrait traiter les requêtes par paquets de 10 par exemple, mais l’idéal serait une fenêtre glissante : on en lance 10, et à chaque fois qu’une termine on en relance une, de sorte qu’on en ait toujours 10 qui tournent.
Il y a une petite bibliothèque qui permet précisément de faire ça : p-limit
(https://www.npmjs.com/package/p-limit)
import _ from 'lodash'; import pLimit from 'p-limit'; async function getWeather({ city }) { await new Promise((resolve) => setTimeout(resolve, 10)); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); } const cities = _.range(1000).map(n => `Ville ${n}`); // Vous pouvez jouer avec la limite pour voir l'impact sur le temps d'exécution const limit = pLimit(10); // On va wrapper l'appel avec p-limit, qui gèrera quand déclencher la fonction const limitedGetWeather = (city) => limit( async () => [city, await getWeather({ city })], ); const results = Object.fromEntries( await Promise.all( cities.map(limitedGetWeather), ), ); console.log(results);
Il y a d’ailleurs toute une famille de bibliothèques autour de p-limit
: p-all
, p-map
…
Je vous invite à y jeter un oeil, elles sont bien pratiques et assez légères.
En jouant sur le nombre de requêtes concurrentes qu’on autorise, on peut arriver à coller à peu près à ce que tolère l’API.
Mais on peut faire mieux.
Version 3 – parallèle juste ce qu’il faut
La plupart des API explicitent leur politique de rate-limit dans leur documentation. Elle est généralement exprimée en nombre de requêtes par unité de temps, par exemple 10000 requêtes par minute.
Et bien là encore, une bibliothèque qui gère ça très bien : bottleneck
(https://www.npmjs.com/package/bottleneck)
Contrairement à p-limit
, bottleneck
est assez costaude, mais gère tout un tas de situations :
- “réservoirs” (on vous redonne 100 requêtes toutes les 10 secondes par exemple)
- groupes : un ensemble de limiteurs similaires identifiés par une clé
- distribué : on peut le connecter à un redis et le limiteur est synchronisé entre plusieurs services
- politique de “débordement” : si on fait beaucoup plus de requêtes que la limite autorisée, les appels vont s’accumuler dans la file d’attente ; on peut décider de limiter la taille de cette file et de définir ce qu’on fait quand ça “déborde”
- etc.
On peut ainsi traiter à peu près tous les cas de figure. Mais pour aujourd’hui, restons sur l’exemple simple de 10000 requêtes par minute.
import _ from 'lodash'; import Bottleneck from 'bottleneck'; // Vous pouvez jouer avec les limites pour voir l'impact sur le temps d'exécution const limiter = new Bottleneck({ // 10000 requêtes par minute => 6ms entre chaque requête minTime: 6, // c'est généralement une bonne idée d'aussi limiter la concurrence maxConcurrent: 5, }); // On wrap l'appel à l'API avec bottleneck, pour en limiter le flux const getWeather = limiter.wrap(async ({ city }) => { await new Promise((resolve) => setTimeout(resolve, 10)); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); }); const cities = _.range(1000).map(n => `Ville ${n}`); const results = Object.fromEntries( await Promise.all( cities.map( async (city) => [city, await getWeather({ city })], ), ), ); console.log(results);
Grâce à bottleneck on peut être au plus proche du rate limit de l’API, et ainsi aller le plus vite possible sans se faire flasher.
Version 4 – gestion d’erreur
Je crois que c’est La Fontaine qui disait que rien ne sert de courir si c’est pour se gauffrer sur la première erreur venue (https://fr.wikipedia.org/wiki/Le_Lièvre_et_la_Tortue_(La_Fontaine)).
Et bien c’est exactement ce qu’on est en train de faire. Si une seule requête échoue, elle entraîne toutes les autres dans sa chute, et on se retrouve sans aucun résultat.
Ça peut être souhaitable vous me direz. Mais supposons pour l’exemple qu’on préfère savoir le temps qu’il fait dans 99% des villes plutôt que dans aucune.
En plus, le Promise.all
a un comportement qui peut surprendre : toutes nos requêtes vont s’exécuter, quoi qu’il arrive. Certaines peuvent faire des erreurs, ça n’arrête pas les suivantes pour autant. Et cerise sur le gâteau, l’erreur qui va remonter au final sera seulement la première de la liste.
En résumé, en cas d’erreur il est difficile de savoir exactement ce qui s’est passé et ce qui a fonctionné ou pas. Ça va qu’on ne fait que des GET
, imaginez si on était en train de passer des transactions bancaires…
On peut mettre un try/catch
dans notre fonction getWeather
, et traiter chaque erreur individuellement. Ça laisse la possibilité de re-throw l’erreur en cas de panique.
Mais si on souhaite gérer les erreurs dans leur ensemble, on a une autre fonction : Promise.allSettled
(disponible à partir de node 12.10.0 https://node.green/#ES2020-features–Promise-allSettled).
import _ from 'lodash'; import Bottleneck from 'bottleneck'; const limiter = new Bottleneck({ minTime: 6, maxConcurrent: 5, }); const getWeather = limiter.wrap(async ({ city }) => { await new Promise((resolve) => setTimeout(resolve, 10)); // On a 1 chance / 10 d'avoir une erreur if (_.random(10) === 0) throw new Error('ECONNRESET'); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); }); const cities = _.range(1000).map(n => `Ville ${n}`); // On zip les villes avec le résultat des requêtes pour faire des paires [ville, resultat] const results = _.zip( cities, await Promise.allSettled( cities.map( (city) => getWeather({ city }), ), ), ); // Chaque résultat de Promise.allSettled est de la forme : { status: 'fulfilled', value: 'Résultat' } // ou { status: 'rejected', reason: 'Erreur' } // Ce qui avec le zip donne un results de la forme : // [ // ['Ville 1', { status: 'fulfilled', value: 'Soleil' }], // ['Ville 2', { status: 'fulfilled', value: 'Pluie' }], // ['Ville 3', { status: 'rejected', reason: 'ECONNRESET' }], // ['Ville 4', { status: 'fulfilled', value: 'Pluie' }], // ] // _.partition va nous séparer la liste des résultats en deux listes, selon que la requête a réussi ou non const [successes, failures] = _.partition(results, ['1.status', 'fulfilled']); if (!_.isEmpty(failures)) { console.error( _.chain(failures) .groupBy('1.reason') .mapValues((results) => _.map(results, _.first)) .value(), ); } console.log( _.chain(successes) .fromPairs() .mapValues('value') .value(), );
Promise.allSettled
nous retourne la description du résultat de l’ensemble de nos requêtes, qu’elles aient réussi ou pas. Libre à nous de gérer les succès et erreurs comme on le souhaite.
Maintenant, toutes les erreurs ne se valent pas.
Si on demande une ville qui n’existe pas par exemple, on peut se contenter de retourner une météo undefined
.
Mais si le service est juste momentanément indisponible, on peut vouloir essayer une nouvelle fois.
Version 5 – on persévère
Un des grands préceptes Shadok nous enseigne que plus ça rate, plus on a de chance que ça marche (https://fr.wikipedia.org/wiki/Les_Shadoks).
Dans notre cas, si l’API nous renvoie une erreur momentanée, ou indéterminée, on va vouloir retenter notre chance, jusqu’à ce que ça retombe en marche.
Vous me voyez venir ?
Bien vu, j’ai une bibliothèque pour ça : async-retry
(https://www.npmjs.com/package/async-retry)
Elle permet de réessayer une requête en erreur, avec de chouettes options :
- délai entre les tentatives, avec un facteur si on veut l’augmenter de manière exponentielle, et également un facteur aléatoire si on veut
- limite en nombre de tentatives et/ou en temps
- annuler les tentatives restantes, en cas d’erreur fatale par exemple
On peut ainsi faire quelque chose comme :
import _ from 'lodash'; import Bottleneck from 'bottleneck'; import retry from 'async-retry'; const limiter = new Bottleneck({ minTime: 6, maxConcurrent: 5, }); const getWeather = limiter.wrap( async () => { await new Promise((resolve) => setTimeout(resolve, 10)); if (_.random(10) === 0) throw new Error('ECONNRESET'); return _.sample(['Soleil', 'Nuages', 'Pluie', 'Neige']); }, ); // On wrap l'appel avec async-retry, pour la gestion d'erreur const getWeatherRetry = async ({ city }) => retry( async () => getWeather({ city }), { // On retente toutes les 100ms dans la limite de 5 fois retries: 5, minTimeout: 100, factor: 1, } ); const cities = _.range(1000).map(n => `Ville ${n}`); const results = _.zip( cities, await Promise.allSettled( cities.map( (city) => getWeatherRetry({ city }), ), ), ); const [successes, failures] = _.partition(results, ['1.status', 'fulfilled']); if (!_.isEmpty(failures)) { console.error( _.chain(failures) .groupBy('1.reason') .mapValues((results) => _.map(results, _.first)) .value(), ); } console.log( _.chain(successes) .fromPairs() .mapValues('value') .value(), );
Attention toutefois : c’est sympa de réessayer les requêtes jusqu’à ce que ça marche, comme un bourrin. Quand on GET
de la donnée comme ici, il ne peut rien se passer de trop gênant. Si la requête fait des modifications en revanche, ça n’est pas toujours une bonne idée.
Prenons un exemple au hasard : imaginez que Netflix fasse ça sur la requête de paiement de votre abonnement, et que celle-ci soit rejouée 10 fois. Eh ben ça va pas faire une soirée très “chill”, comme disent les jeunes.
Il faut donc toujours se poser la question de l’impact de faire la requête plusieurs fois sur le fonctionnement du système, avant de mettre en place une telle logique.
C’est aussi sympa pour l’API de mettre un délai exponentiel. Si elle est sous l’eau, vous lui laisserez ainsi une chance de reprendre ses esprits avant de vous répondre.
Conclusion
En combinant une logique de rate-limit avec une bonne gestion d’erreur, comme on a pu le faire avec le combo gagnant bottleneck
+ async-retry
, on arrive à un code relativement tout-terrain.
On l’utilise chez Indy pour certaines synchronisations un peu capricieuses, et ça tourne comme une horloge.
Et pour finir sur une note plus sérieuse, gardez à l’esprit qu’il y a aussi des dev derrière les API sur lesquelles on aime râler, et que c’est très difficile, voire impossible, de fournir un service parfaitement fiable. Soyez compréhensifs, et raisonnables dans vos intégrations, ne faites pas les bourrins 😉