À l’intérieur d’un service de notification multi-fournisseurs : files d’attente, solutions de secours, confiance
Une présentation détaillée de la conception d’un service de notification fiable : stockage des identifiants, mécanisme de secours avec d’autres fournisseurs, tentatives répétées via BullMQ et utilisation des files de messages en attente, équité entre les utilisateurs ainsi que vérification des signatures des webhooks.
Sources: Building NotifyKit: The Engineering Behind Reliable Notifications
Envoyer un e-mail ou un SMS semble être une tâche simple : il suffit d’appeler l’API du fournisseur et de continuer. En environnement de production, cette unique appel send() se transforme silencieusement en un problème de systèmes distribués, car les fournisseurs mettent des délais d’attente, imposent des limites de fréquence, se déconnectent, livrent les messages dans le mauvais ordre, et les tentatives de réexpédition peuvent générer des doublons. Cet article analyse l’architecture d’un service de notification open source multi-locataires basé sur Node.js, BullMQ et Redis, en abordant sept aspects : les secrets, les pannes des fournisseurs, la conception de la file d’attente, l’ordre de livraison, la fiabilité de la livraison, l’équité entre les locataires et la vérification des webhook. Pour chacun de ces aspects, vous découvrirez la décision prise, les raisons qui l’ont motivée ainsi que les limites acceptées consciemment, afin de pouvoir reprendre ces principes dans vos propres systèmes.
Deux types de secrets, deux stratégies de stockage
Clés du fournisseur : chiffrer, car elles doivent pouvoir être récupérées
Le texte brut ne touche jamais la base de données, les journaux ou les caches. Il est déchiffré en exact deux endroits : pour afficher une prévisualisation masquée dans le tableau de bord, et à l’intérieur du processus d’envoi juste avant la demande adressée au fournisseur externe. En limitant le déchiffrement à quelques chemins de code bien définis, il devient beaucoup plus facile d’auditer les fuites accidentelles.
Clés API émises par la plateforme : hachage, car elles n’ont besoin que d’être vérifiées
Les clés API que la plateforme émet à ses propres clients suivent une logique inverse. Le système n’a jamais besoin de les récupérer, seulement de confirmer que la clé présentée est authentique. Ainsi, la clé est affichée une seule fois lors de sa création, et seul son hachage SHA-256 est conservé. À chaque demande, la clé reçue est hachée et comparée au digest stocké. Une clé perdue ne peut pas être récupérée et doit être régénérée.
Un hachage rapide comme SHA-256 est approprié ici, car les clés sont de longues chaînes aléatoires à haute entropie. Ce même choix serait erroné pour les mots de passe humains, qui nécessitent un algorithme délibérément lent tel que bcrypt, scrypt ou Argon2 pour résister aux tentatives de devinette.
Secrèts de signature des webhooks : entre les deux
Le secret utilisé pour signer les webhooks sortants est généré par la plateforme, à l’instar d’une clé API, mais sa valeur brute est nécessaire chaque fois qu’un webhook doit être signé, comme c’est le cas pour une clé de fournisseur. Il est donc chiffré avec AES-256-GCM en état de repos, déchiffré juste à temps pour chaque opération de signature, et conservé uniquement en mémoire tant qu’il est utilisé.
Faire face à des fournisseurs peu fiables grâce à un mécanisme de secours ordonné
Tout système lié à un seul fournisseur subit les pannes, les restrictions de débit et les temps d’attente de ce dernier. Le service réduit cet impact grâce à une liste de priorités par client. Un client peut enregistrer plusieurs fournisseurs, tels que SendGrid, Resend et Postmark, et les classer par ordre de priorité. Lorsqu’une demande d’envoi ne spécifie pas de fournisseur, la liste des tentatives est établie en fonction de cette hiérarchie, en commençant par le fournisseur ayant la priorité la plus élevée.
L’envoi est tenté successivement sur cette liste. Si un fournisseur échoue en raison d’une panne, de restrictions de débit ou d’un rythme trop lent, l’échec est enregistré et le fournisseur suivant est essayé. La première tentative réussie met fin au processus, et le client ne remarque jamais la solution de secours à moins que tous les fournisseurs échouent.
Les clients peuvent également assigner un message à un seul fournisseur, éventuellement avec une solution de secours unique ; dans ce cas, seules ces deux entités sont testées. Tous les erreurs ne sont pas traitées de la même manière :
- Faillites permanentes, telles qu’une identifiante rejetée ou une requête mal formatée, entraînent un échec immédiat, car les tentatives de réessai ne peuvent pas les résoudre.
- Faillites transitoires, comme les délais d’attente, les limites de fréquence et les réponses 5xx du fournisseur, sont soumises à des tentatives de réessai avec un mécanisme d’attente progressive.
Lorsque toute la liste échoue, l’erreur remonte jusqu’à la file d’attente, dont la propre politique de réessai prend le relais. Le mécanisme de fallback ne fait pas disparaître les faillites du fournisseur ; il donne à chaque message davantage de chances de réussir avant que l’échec ne devienne visible pour le client.
Un détail important à prendre en compte : un timeout ne prouve pas que le fournisseur a rejeté le message. Un fournisseur peut accepter une demande puis échouer à y répondre à temps, ce qui pourrait entraîner l’envoi de la même notification deux fois. Utiliser une clé d’idempotence lorsque le fournisseur la prend en charge, ou accepter occasionnellement des doublons pour les messages peu importants, sont les méthodes habituelles pour gérer ce problème. Notre article sur les clés d’idempotence pour les endpoints POST de Node.js aborde ce sujet en détail.
Architecture de file d’attente
Fournir une notification au sein de la demande API qui l’a déclenchée lie le temps de réponse du client à celui du fournisseur le plus lent. Un fournisseur lent ou indisponible ferait en sorte que la requête HTTP du client reste bloquée jusqu’à ce qu’elle réussisse finalement ou que le délai imparti soit écoulé. Le service évite cela en séparant l’acceptation de la livraison.
Découplage de la demande de la livraison
L’API se contente de valider la demande, de créer une ligne Job dans la base de données et d’insérer le travail dans une file d’attente. BullMQ sur Redis s’occupe de la planification, avec une file d’attente par type de travail :
notifications-emailpour les e-mails des clientsnotifications-webhookpour les webhooks des clientsplatform-emailpour les alertes internes du service lui-mêmenotifications-faileden tant que file d’attente de lettres mortes pour les tâches ayant épuisé leurs tentatives
Des files distinctes permettent à un type de tâche de s’accumuler ou d’être ajusté sans affecter les autres. Pour en savoir plus sur la conception des tâches dans BullMQ, consultez la création de systèmes de tâches en arrière-plan fiables avec BullMQ et Redis.
Le comptage des tentatives concerne les passes complètes, pas les fournisseurs
Chaque tâche dispose de trois tentatives avec un retard exponentiel, et le détail important concerne la signification d’une « tentative ». Il ne s’agit pas d’une tentative par fournisseur. Une seule tentative correspond à une vérification complète des fournisseurs du client dans l’ordre de priorité, en s’arrêtant au premier succès. Lorsque tous les fournisseurs testés échouent, la tâche a utilisé une tentative, et la prochaine tentative reprend avec le fournisseur ayant la plus haute priorité. Ce n’est qu’après trois tentatives infructueuses que la tâche, y compris l’erreur, est transférée dans la file de courrier indésirable, et l’original est marqué FAILED.
La base de données reste la source de vérité, même en cas de panne de la file d’attente elle-même. Une tâche client Job, ou un PlatformEmailLog pour les e-mails internes, est enregistrée avec le statut PENDING avant toute mise en file. Si Redis est inaccessible ou que l’ajout de la tâche provoque une erreur, celle-ci est enregistrée, l’échec est signalé et la ligne correspondante est mise à jour en FAILED avec l’explication. Aucune demande ne disparaît silencieusement entre l’API et la file d’attente.
À quoi sert la file de lettres mortes
Lorsqu’une tâche épuise ses trois tentatives, une copie est stockée dans notifications-failed, contenant le contenu complet, l’erreur ainsi qu’une date et heure, tandis que l’enregistrement dans la base de données est mis à FAILED avec la même raison. Enregistrer l’échec deux fois est intentionnel, car chaque enregistrement s’adresse à un public différent :
- La ligne de base de données est destinée aux clients et prend en charge la requête
GET /jobs/:id. - L’entrée du DLQ est destinée aux opérateurs ; il s’agit d’une copie temporaire conçue pour être surveillée et traitée.
Un DLQ n’a de valeur que s’il est surveillé. Chaque tâche arrivée déclenche une alerte Slack, en réutilisant le mécanisme d’alerte déjà mis en place pour les échecs d’envoi par e-mail sur la plateforme. Cela transforme la file en un signal d’alerte précoce : une augmentation soudaine du nombre de tâches classées comme « dead-letter » sur une courte période indique généralement un problème systémique, comme une panne du fournisseur ou un déploiement défaillant.
Rien ne quitte automatiquement le DLQ. Une tâche échouée n’est réessayée que lorsque le client appelle POST /jobs/:id/retry, ce qui la réinitialise en état PENDING et relance le processus.
Priorité et concurrence
Les tâches possèdent l’une de trois priorités : CRITICAL, NORMAL ou LOW, qui correspondent au système de priorités natif de BullMQ. Le trafic du plan gratuit est toujours forcé au niveau LOW afin qu’il ne puisse pas concurrencer le trafic payant. Les e-mails de la plateforme ignorent complètement cette logique : ils sont toujours enregistrés avec la priorité CRITICAL et utilisent un délai d’attente plus court.
Les groupes de travail récupèrent continuellement des tâches, chacune étant exécutée en parallèle avec cinq autres tâches, ce qui permet de traiter jusqu’à cinq tâches par groupe en même temps.
Ordre de traitement : une différence intentionnelle
Le service ne garantit pas un ordre de traitement par client. La concurrence et les priorités sont ajustées en fonction du débit, et avec cinq travailleurs parallèles ainsi que des tentatives de réexpédition, deux messages envoyés séquentiellement peuvent facilement être livrés dans l’ordre inverse. Pour la plupart des notifications, cela n’a pas d’impact car elles sont indépendantes. Cela devient un problème uniquement pour des paires liées causalement où l’ordre a de l’importance, comme un message qui devrait logiquement suivre un autre.
Il existe deux solutions générales, chacune ayant un coût :
- Sérialiser par client. Diriger les tâches d’un client via un seul canal afin de garantir l’ordre, au détriment du parallélisme de ce client.
- Rendre les messages tolérants à l’ordre. Ajouter des timestamps et laisser les consommateurs interpréter eux-mêmes la séquence, ce qui transfère la responsabilité à la manière dont les clients lisent les livraisons.
Cette limitation est connue et a été acceptée au profit du débit. L’utilisation actuelle ne justifie pas encore ces coûts, donc la décision est reportée en attendant que des données réelles de clients montrent quel compromis convient le mieux. Documenter explicitement un tel manque est souvent préférable à une solution partielle.
Faisabilité de livraison : prévention des échecs avant et après l’envoi
Les e-mails peuvent être renvoyés, classés dans la boîte de spam ou disparaître en fonction de la réputation de l’expéditeur et des signaux accumulés au fil du temps. Le service traite ce problème en deux étapes.
Au préalable de l’envoi : vérification du domaine
Lorsqu’un client souhaite envoyer des messages depuis son propre domaine, le service enregistre ce domaine auprès du fournisseur choisi par le client et reçoit les enregistrements DNS prouvant la propriété. Ces enregistrements diffèrent d’un fournisseur à l’autre, ce qui permet au client de les ajouter sur son hébergeur DNS. L’envoi depuis ce domaine reste bloqué jusqu’à ce que la vérification soit réussie.
Après l’envoi : réaction aux événements du fournisseur
Les fournisseurs signalent les livraisons, les ouvertures, les clics, les rebonds ainsi que les plaintes pour spam via des webhooks. Le service stocke ces événements et réagit en fonction de leur type :
- Un rebond définitif (l’adresse n’existe pas ou est refusée de manière permanente) entraîne la suppression automatique du destinataire, et les futures tentatives d’envoi vers cette adresse sont bloquées avant même qu’un appel au fournisseur ne soit effectué.
- Une plainte pour spam déclenche également cette suppression.
La règle est que seuls les signaux indiquant un problème permanent entraînent un blocage. Cela ne résout pas complètement le problème de livrabilité, puisque la réputation évolue en fonction de facteurs dépassant le cadre d’un seul message, mais cela empêche le système de contacter à répétition des adresses connues pour échouer, ce qui est lui-même l’un des facteurs nuisant à la réputation.
Empêcher les utilisateurs de s’entraider
Tous les clients partagent les mêmes files d’attente, les mêmes groupes de travailleurs et l’instance Redis ; rien n’est physiquement isolé. L’équité provient de trois mécanismes de contrôle appliqués à différents stades.
Premier obstacle : limites de vitesse par client à l’API
- FREE : 5 demandes par minute
- INDIE : 50 demandes par minute
- STARTUP : 200 demandes par minute
LOW. Les travailleurs traitent d’abord les tâches à plus haute priorité, de sorte que même si un compte gratuit accumule des retards, les notifications des clients payants sont traitées en premier.
Il n’y a pas d’isolation stricte des ressources : aucun travailleur dédié par client. Deux clients appartenant au même niveau de priorité partagent la même capacité, et au sein d’un même niveau, les tâches sont traitées approximativement selon le principe du premier arrivé, premier servi. Avec le volume actuel, cela n’a pas posé de problème, car le trafic reste bien en deçà des capacités de l’infrastructure partagée et aucun client n’a ralenti significativement les autres. À plus grande échelle, une isolation par client ou un système de planification équitable (par exemple, en rotation entre les clients d’un même niveau de priorité) deviendrait nécessaire.
Vérification des webhooks dans les deux sens
Tout fournisseur intégré envoie des événements via des webhooks : confirmations de livraison, retours d’erreur, signalements de spam et mises à jour de l’état des paiements. Chaque point de terminaison de réception est une URL publique. Sans vérifier l’expéditeur, n’importe qui pourrait publier un événement de « livraison » falsifié, fabriquer un retour d’erreur pour faire supprimer l’adresse d’une autre personne, ou, ce qui est le plus préjudiciable, simuler un événement de paiement pour indiquer qu’une abonnement a été payé.
Entrée : un schéma par fournisseur
Il n’existe pas de norme commune, donc chaque intégration représente une tâche de sécurité à part entière :
- SendGrid signe les données avec ECDSA, et la signature est vérifiée à l’aide de la clé publique de SendGrid avant que les données ne soient considérées comme fiables.
- Resend transmet les webhooks via Svix, qui utilise un schéma HMAC daté permettant de se protéger à la fois contre les modifications et les retransmissions non autorisées.
standardwebhooks.Le cas Postmark présente un piège facile à manquer : le secret doit être comparé en temps constant, par exemple avec crypto.timingSafeEqual de Node, et non à l’aide d’une vérification normale d’égalité de chaîne. Une comparaison ordinaire peut s’arrêter dès qu’un caractère diffère, et ces différences de temps peuvent permettre à un attaquant de récupérer le secret caractère par caractère au lieu de forcer la valeur entière.
Aucun de ces schémas n’est interchangeable, et chaque intégration doit être apprise et mise en œuvre à partir de zéro. L’invariant commun à tous est que rien ce qui arrive à une extrémité webhook n’est traité avant que son origine ne soit vérifiée.
Sortante : permettre aux clients de faire confiance à la plateforme
Le même problème se pose dans le sens inverse lorsque le service envoie des webhooks vers les extrémités des clients. Si un client a configuré un secret de signature, chaque livraison inclut une en-tête X-Webhook-Timestamp et une en-tête X-Webhook-Signature. La signature est un HMAC-SHA256 calculé à partir de la chaîne timestamp.payload, ce qui lie le corps du message à un moment précis. Le destinataire recalcule la signature et rejette également les timestamps trop anciens, ce qui empêche une demande capturée d’être rejouée ultérieurement.
Les clients ont un contrôle total sur leur secret : ils peuvent le générer, vérifier son état, le renouveler ou le supprimer. Même en l’absence de secret configuré, les webhooks sont toujours transmis, mais sans signature. Cela favorise la compatibilité rétrograde, mais cela signifie également que les destinataires doivent activer la vérification pour être protégés. Pour une perspective complémentaire du côté récepteur, consultez Comprendre les clés d’idempotence dans les points de terminaison POST de Node.js.
Un repli minimal, puis la vérification de signature
deliver parcourt les fournisseurs dans l’ordre et renvoie le premier qui accepte le message. Relancer toute la tâche revient à la file.
type Provider = {
name: string;
send: (to: string, body: string) => Promise<void>;
};
const deliver = async (providers: Provider[], to: string, body: string) => {
const errors: string[] = [];
for (const provider of providers) {
try {
await provider.send(to, body);
return provider.name;
} catch (err) {
errors.push(`${provider.name}: ${(err as Error).message}`);
}
}
throw new Error(`all providers failed\n${errors.join('\n')}`);
};
La signature couvre timestamp.payload. Le récepteur refuse un MAC faux et un horodatage de plus de cinq minutes.
import { createHmac, timingSafeEqual } from 'node:crypto';
const sign = (secret: string, timestamp: string, payload: string) =>
createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
const verify = (
secret: string,
timestamp: string,
payload: string,
signature: string,
) => {
const ageMs = Math.abs(Date.now() - Number(timestamp));
if (!Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000) return false;
const expected = Buffer.from(sign(secret, timestamp, payload));
const received = Buffer.from(signature);
if (expected.length !== received.length) return false;
return timingSafeEqual(expected, received);
};
Points clés
- Décidez entre chiffrage et hachage en vous demandant si la valeur originale doit un jour être récupérée.
- Considérez les mécanismes de secours du fournisseur et les tentatives de réessai dans la file d’attente comme des couches distinctes, et définissez précisément ce qu’une « tentative » signifie.
- Créez un enregistrement durable dans la base de données avant d’insérer une demande dans la file, afin qu’une panne de cette dernière ne fasse pas perdre la requête.